2
0

Add usage-based new pricing plans

This commit is contained in:
Baptiste Arnaud
2022-09-17 16:37:33 +02:00
committed by Baptiste Arnaud
parent 6a1eaea700
commit 898367a33b
144 changed files with 4631 additions and 1624 deletions

View File

@ -20,13 +20,14 @@
</mj-section>
<mj-section padding-top="20px">
<mj-column>
<mj-text>You have been invited to collaborate on a typebot created by ${email}</mj-text>
<mj-text>From now on you will see this typebot in your dashboard under the "Shared with me "button 👍</mj-text>
<mj-text>You have been invited by ${hostEmail} to collaborate on his typebot <strong>${typebotName}</strong></mj-text>
<mj-text>From now on you will see this typebot in your dashboard under the his workspace "${workspaceName}" 👍</mj-text>
<mj-text>Make sure to log in as ${guestEmail}</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-button background-color="#0042da" color="white" href="${url}" font-weight="500" border-radius="5px">See the typebot</mj-button>
<mj-button background-color="#0042da" color="white" href="${url}" font-weight="500" border-radius="5px">Go to typebot</mj-button>
</mj-column>
</mj-section>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,37 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif" font-size="16px" padding="0" line-height="23px"></mj-all>
<mj-section background-color="#ffffff" padding-bottom="20px"></mj-section>
<mj-text padding="10px 40px"></mj-text>
</mj-attributes>
<mj-style inline="inline">
.footer-link {
color: #A0AEC0
}
</mj-style>
</mj-head>
<mj-body background-color="#ffffff">
<mj-wrapper border="1px solid #E2E8F0">
<mj-section padding-bottom="0px">
<mj-column width="100%">
<mj-image src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png" alt="header image" padding="0px"></mj-image>
</mj-column>
</mj-section>
<mj-section padding-top="20px">
<mj-column>
<mj-text>You have been invited by ${hostEmail} to collaborate on his workspace "${workspaceName}" as a team member.</mj-text>
<mj-text>From now on you will have access to this workspace in your dashboard 👍</mj-text>
<mj-text>Make sure to log in as ${guestEmail}</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-button background-color="#0042da" color="white" href="${url}" font-weight="500" border-radius="5px">Go the workspace</mj-button>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View File

@ -0,0 +1,536 @@
type Props = {
workspaceName: string
url: string,
hostEmail: string,
guestEmail: string
}
export const workspaceMemberInvitationEmail = ({workspaceName, url, hostEmail, guestEmail}: Props) => `<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #ffffff">
<div style="background-color: #ffffff">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
border: 1px solid #e2e8f0;
direction: ltr;
font-size: 0px;
padding: 0;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 598px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background: #ffffff;
background-color: #ffffff;
width: 100%;
"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0;
padding-bottom: 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 598px">
<img
alt="header image"
height="auto"
src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png"
style="
border: 0;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 16px;
"
width="598"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 598px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background: #ffffff;
background-color: #ffffff;
width: 100%;
"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0;
padding-bottom: 20px;
padding-top: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 40px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica Neue,
Helvetica, Helvetica, Arial,
sans-serif;
font-size: 16px;
line-height: 23px;
text-align: left;
color: #000000;
"
>
You have been invited by
${hostEmail} to collaborate on his
workspace "${workspaceName}" as a
team member.
</div>
</td>
</tr>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 40px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica Neue,
Helvetica, Helvetica, Arial,
sans-serif;
font-size: 16px;
line-height: 23px;
text-align: left;
color: #000000;
"
>
From now on you will have access
to this workspace in your
dashboard üëç
</div>
</td>
</tr>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 40px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica Neue,
Helvetica, Helvetica, Arial,
sans-serif;
font-size: 16px;
line-height: 23px;
text-align: left;
color: #000000;
"
>
Make sure to log in as
${guestEmail}
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 598px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background: #ffffff;
background-color: #ffffff;
width: 100%;
"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0;
padding-bottom: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td
align="center"
vertical-align="middle"
style="
font-size: 0px;
padding: 0;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: separate;
line-height: 100%;
"
>
<tbody>
<tr>
<td
align="center"
bgcolor="#0042da"
role="presentation"
style="
border: none;
border-radius: 5px;
cursor: auto;
mso-padding-alt: 10px 25px;
background: #0042da;
"
valign="middle"
>
<a
href="${url}"
style="
display: inline-block;
background: #0042da;
color: white;
font-family: Helvetica
Neue,
Helvetica, Helvetica,
Arial, sans-serif;
font-size: 16px;
font-weight: 500;
line-height: 23px;
margin: 0;
text-decoration: none;
text-transform: none;
padding: 10px 25px;
mso-padding-alt: 0px;
border-radius: 5px;
"
target="_blank"
>Go the workspace</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`

View File

@ -481,3 +481,11 @@ export const EyeOffIcon = (props: IconProps) => (
<line x1="1" y1="1" x2="23" y2="23"></line>
</Icon>
)
export const AlertIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</Icon>
)

View File

@ -2,7 +2,7 @@ import { Flex, Spinner, useDisclosure } from '@chakra-ui/react'
import { StatsCards } from 'components/analytics/StatsCards'
import { Graph } from 'components/shared/Graph'
import { useToast } from 'components/shared/hooks/useToast'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
import { GraphProvider, GroupsCoordinatesProvider } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { Stats } from 'models'
@ -49,7 +49,7 @@ export const AnalyticsContent = ({ stats }: { stats?: Stats }) => {
<Spinner color="gray" />
</Flex>
)}
<UpgradeModal onClose={onClose} isOpen={isOpen} />
<ChangePlanModal onClose={onClose} isOpen={isOpen} />
<StatsCards stats={stats} pos="absolute" top={10} />
</Flex>
)

View File

@ -1,4 +1,5 @@
import { DashboardFolder, WorkspaceRole } from 'db'
import { env } from 'utils'
import {
Flex,
Heading,
@ -160,9 +161,13 @@ export const FolderContent = ({ folder }: Props) => {
return (
<Flex w="full" flex="1" justify="center">
{typebots && !isTypebotLoading && user && folder === null && (
<OnboardingModal totalTypebots={typebots.length} />
)}
{typebots &&
!isTypebotLoading &&
user &&
folder === null &&
env('E2E_TEST') !== 'true' && (
<OnboardingModal totalTypebots={typebots.length} />
)}
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>

View File

@ -1,7 +1,9 @@
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
import {
LimitReached,
ChangePlanModal,
} from 'components/shared/modals/ChangePlanModal'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { isFreePlan } from 'services/workspace'
@ -26,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
<Text>Create a folder</Text>
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
</HStack>
<UpgradeModal
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={LimitReached.FOLDER}

View File

@ -0,0 +1,34 @@
import { Stack } from '@chakra-ui/react'
import { ChangePlanForm } from 'components/shared/ChangePlanForm'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
import { CurrentSubscriptionContent } from './CurrentSubscriptionContent'
import { InvoicesList } from './InvoicesList'
import { UsageContent } from './UsageContent/UsageContent'
export const BillingContent = () => {
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
return (
<Stack spacing="10" w="full">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
<UsageContent workspace={workspace} />
{workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.OFFERED && (
<ChangePlanForm />
)}
{workspace.stripeId && <InvoicesList workspace={workspace} />}
</Stack>
)
}

View File

@ -0,0 +1,77 @@
import {
Text,
HStack,
Link,
Spinner,
Stack,
Flex,
Button,
} from '@chakra-ui/react'
import { PlanTag } from 'components/shared/PlanTag'
import { Plan } from 'db'
import React, { useState } from 'react'
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
type CurrentSubscriptionContentProps = {
plan: Plan
stripeId?: string | null
onCancelSuccess: () => void
}
export const CurrentSubscriptionContent = ({
plan,
stripeId,
onCancelSuccess,
}: CurrentSubscriptionContentProps) => {
const [isCancelling, setIsCancelling] = useState(false)
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
useState(false)
const cancelSubscription = async () => {
if (!stripeId) return
setIsCancelling(true)
await cancelSubscriptionQuery(stripeId)
onCancelSuccess()
setIsCancelling(false)
}
if (isCancelling) return <Spinner colorScheme="gray" />
return (
<Stack gap="2">
<HStack>
<Text>Current workspace subscription: </Text>
<PlanTag plan={plan} />
</HStack>
{(plan === Plan.STARTER || plan === Plan.PRO) && stripeId && (
<>
<Stack gap="1">
<Text fontSize="sm">
Need to change payment method or billing information? Head over to
your billing portal:
</Text>
<Button
as={Link}
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
onClick={() => setIsRedirectingToBillingPortal(true)}
isLoading={isRedirectingToBillingPortal}
>
Billing Portal
</Button>
</Stack>
<Flex>
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
</Flex>
</>
)}
</Stack>
)
}

View File

@ -0,0 +1,97 @@
import {
Stack,
Heading,
Checkbox,
Skeleton,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
IconButton,
Text,
} from '@chakra-ui/react'
import { DownloadIcon, FileIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { Workspace } from 'db'
import React from 'react'
import { useInvoicesQuery } from './queries/useInvoicesQuery'
type Props = {
workspace: Workspace
}
export const InvoicesList = ({ workspace }: Props) => {
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Invoices</Heading>
{invoices.length === 0 && !isLoading ? (
<Text>No invoices found for this workspace.</Text>
) : (
<TableContainer>
<Table>
<Thead>
<Tr>
<Th w="0" />
<Th>#</Th>
<Th>Paid at</Th>
<Th>Subtotal</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{invoices?.map((invoice) => (
<Tr key={invoice.id}>
<Td>
<FileIcon />
</Td>
<Td>{invoice.id}</Td>
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
<Td>
<IconButton
as={NextChakraLink}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
isExternal
aria-label={'Download invoice'}
/>
</Td>
</Tr>
))}
{isLoading &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>
<Checkbox isDisabled />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</Stack>
)
}
const getFormattedPrice = (amount: number, currency: string) => {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
})
return formatter.format(amount / 100)
}

View File

@ -0,0 +1,158 @@
import {
Stack,
Flex,
Heading,
Progress,
Text,
Skeleton,
HStack,
Tooltip,
} from '@chakra-ui/react'
import { AlertIcon } from 'assets/icons'
import { Plan, Workspace } from 'db'
import React from 'react'
import { getChatsLimit, getStorageLimit, parseNumberWithCommas } from 'utils'
import { storageToReadable } from './helpers'
import { useUsage } from './useUsage'
type Props = {
workspace: Workspace
}
export const UsageContent = ({ workspace }: Props) => {
const { data, isLoading } = useUsage(workspace.id)
const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0
const workspaceChatsLimit = getChatsLimit(workspace)
const workspaceStorageLimit = getStorageLimit(workspace)
const workspaceStorageLimitGigabites =
workspaceStorageLimit * 1024 * 1024 * 1024
const chatsPercentage = Math.round(
(totalChatsUsed / workspaceChatsLimit) * 100
)
const storagePercentage = Math.round(
(totalStorageUsed / workspaceStorageLimitGigabites) * 100
)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Usage</Heading>
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Chats
</Heading>
{chatsPercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
chats limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> to increase
this limit and continue chatting with your users.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
<Text fontSize="sm" fontStyle="italic" color="gray.500">
(resets on 1st of every month)
</Text>
</HStack>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{parseNumberWithCommas(totalChatsUsed)}
</Skeleton>
<Text>/ {parseNumberWithCommas(workspaceChatsLimit)}</Text>
</HStack>
</Flex>
<Progress
h="5px"
value={chatsPercentage}
rounded="full"
hasStripe
isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
/>
</Stack>
{workspace.plan !== Plan.FREE && (
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Storage
</Heading>
{storagePercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
storage limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> in order to
continue collecting uploaded files. You can also{' '}
<strong>delete files</strong> to free up space.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
</HStack>
<Heading
fontSize="xl"
as="h3"
display="inline-flex"
alignItems="center"
gap="2"
></Heading>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{storageToReadable(totalStorageUsed)}
</Skeleton>
<Text>/ {workspaceStorageLimit} GB</Text>
</HStack>
</Flex>
<Progress
value={storagePercentage}
h="5px"
colorScheme={
totalStorageUsed >= workspaceStorageLimitGigabites
? 'red'
: 'blue'
}
rounded="full"
hasStripe
isIndeterminate={isLoading}
/>
</Stack>
)}
</Stack>
)
}

View File

@ -0,0 +1,7 @@
export const storageToReadable = (bytes: number) => {
if (bytes == 0) {
return '0'
}
const e = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'
}

View File

@ -0,0 +1 @@
export { UsageContent } from './UsageContent'

View File

@ -0,0 +1,16 @@
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env } from 'utils'
export const useUsage = (workspaceId?: string) => {
const { data, error } = useSWR<
{ totalChatsUsed: number; totalStorageUsed: number },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
})
return {
data,
isLoading: !error && !data,
}
}

View File

@ -0,0 +1 @@
export { BillingContent } from './BillingContent'

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const cancelSubscriptionQuery = (stripeId: string) =>
sendRequest({
url: `api/stripe/subscription?stripeId=${stripeId}`,
method: 'DELETE',
})

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const redirectToBillingPortal = ({
workspaceId,
}: {
workspaceId: string
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)

View File

@ -0,0 +1,24 @@
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env } from 'utils'
type Invoice = {
id: string
url: string
date: number
currency: string
amount: number
}
export const useInvoicesQuery = (stripeId?: string | null) => {
const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
}
)
return {
invoices: data?.invoices ?? [],
isLoading: !error && !data,
}
}

View File

@ -1,76 +0,0 @@
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
export const BillingForm = () => {
const { workspace } = useWorkspace()
return (
<Stack spacing="6" w="full">
<HStack>
<Text>Current workspace subscription: </Text>
<PlanTag plan={workspace?.plan} />
</HStack>
{workspace &&
!([Plan.TEAM, Plan.LIFETIME, Plan.OFFERED] as Plan[]).includes(
workspace.plan
) && (
<HStack>
{workspace?.plan === Plan.FREE && (
<UpgradeButton colorScheme="orange" variant="outline" w="full">
Upgrade to Pro plan
</UpgradeButton>
)}
{workspace?.plan !== Plan.TEAM && (
<UpgradeButton
colorScheme="purple"
variant="outline"
w="full"
plan={Plan.TEAM}
>
Upgrade to Team plan
</UpgradeButton>
)}
</HStack>
)}
{workspace?.stripeId && (
<>
<Text>
To manage your subscription and download invoices, head over to your
Stripe portal:
</Text>
<Button
as={NextChakraLink}
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
isExternal
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
>
Stripe Portal
</Button>
</>
)}
</Stack>
)
}
const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.TEAM: {
return <Tag colorScheme="purple">Team</Tag>
}
case Plan.LIFETIME:
case Plan.OFFERED:
case Plan.PRO: {
return <Tag colorScheme="orange">Personal Pro</Tag>
}
default: {
return <Tag colorScheme="gray">Free</Tag>
}
}
}

View File

@ -2,7 +2,7 @@ import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
import { UnlockPlanInfo } from 'components/shared/Info'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import React from 'react'
import {
deleteInvitation,
@ -13,6 +13,7 @@ import {
useMembers,
} from 'services/workspace'
import { AddMemberForm } from './AddMemberForm'
import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem'
export const MembersList = () => {
@ -78,14 +79,19 @@ export const MembersList = () => {
})
}
const canInviteNewMember = checkCanInviteMember({
plan: workspace?.plan,
currentMembersCount: [...(members ?? []), ...(invitations ?? [])].length,
})
return (
<Stack w="full">
{workspace?.plan !== Plan.TEAM && (
<Stack w="full" gap="3">
{!canInviteNewMember && (
<UnlockPlanInfo
contentLabel={
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
}
plan={Plan.TEAM}
contentLabel={`
Upgrade your plan to work with more team members, and unlock awesome
power features 🚀
`}
/>
)}
{workspace?.id && canEdit && (
@ -94,7 +100,7 @@ export const MembersList = () => {
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
isLocked={workspace.plan !== Plan.TEAM}
isLocked={!canInviteNewMember}
/>
)}
{members?.map((member) => (

View File

@ -0,0 +1,15 @@
import { Plan } from 'db'
import { seatsLimit } from 'utils'
export function checkCanInviteMember({
plan,
currentMembersCount,
}: {
plan: string | undefined
currentMembersCount?: number
}) {
if (!plan || !currentMembersCount) return false
if (plan !== Plan.STARTER && plan !== Plan.PRO) return false
return seatsLimit[plan].totalIncluded > currentMembersCount
}

View File

@ -18,7 +18,7 @@ import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { User, Workspace } from 'db'
import { useState } from 'react'
import { BillingForm } from './BillingForm'
import { BillingContent } from './BillingContent'
import { MembersList } from './MembersList'
import { MyAccountForm } from './MyAccountForm'
import { EditorSettingsForm } from './EditorSettingsForm'
@ -50,13 +50,12 @@ export const WorkspaceSettingsModal = ({
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent h="600px" flexDir="row">
<ModalContent minH="600px" flexDir="row">
<Stack
spacing={8}
w="250px"
w="200px"
py="6"
borderRightWidth={1}
h="full"
justifyContent="space-between"
>
<Stack spacing={8}>
@ -134,7 +133,7 @@ export const WorkspaceSettingsModal = ({
justifyContent="flex-start"
pl="4"
>
Billing
Billing & Usage
</Button>
)}
</Stack>
@ -174,7 +173,7 @@ const SettingsContent = ({
case 'members':
return <MembersList />
case 'billing':
return <BillingForm />
return <BillingContent />
default:
return null
}

View File

@ -1,10 +1,8 @@
import { Stack } from '@chakra-ui/react'
import { SubmissionsTable } from 'components/results/ResultsTable'
import React, { useState } from 'react'
import { UnlockPlanInfo } from 'components/shared/Info'
import { LogsModal } from './LogsModal'
import { useTypebot } from 'contexts/TypebotContext'
import { Plan } from 'db'
import { useResults } from 'contexts/ResultsProvider'
import { ResultModal } from './ResultModal'
@ -14,7 +12,6 @@ export const ResultsContent = () => {
fetchMore,
hasMore,
resultHeader,
totalHiddenResults,
tableData,
} = useResults()
const { typebot, publishedTypebot } = useTypebot()
@ -46,13 +43,6 @@ export const ResultsContent = () => {
overflow="scroll"
w="full"
>
{totalHiddenResults && (
<UnlockPlanInfo
buttonLabel={`Unlock ${totalHiddenResults} results`}
contentLabel="You are seeing complete submissions only."
plan={Plan.PRO}
/>
)}
{publishedTypebot && (
<LogsModal
typebotId={publishedTypebot?.typebotId}

View File

@ -38,7 +38,6 @@ export const ResultsActionButtons = ({
resultHeader,
mutate,
totalResults,
totalHiddenResults,
tableData,
onDeleteResults,
} = useResults()
@ -57,7 +56,7 @@ export const ResultsActionButtons = ({
const totalSelected =
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
? totalResults - (totalHiddenResults ?? 0)
? totalResults
: selectedResultsId.length
const deleteResults = async () => {
@ -87,9 +86,7 @@ export const ResultsActionButtons = ({
const exportResultsToCSV = async () => {
setIsExportLoading(true)
const isSelectAll =
totalSelected === 0 ||
totalSelected === totalResults - (totalHiddenResults ?? 0)
const isSelectAll = totalSelected === 0 || totalSelected === totalResults
const dataToUnparse = isSelectAll
? await getAllTableData()

View File

@ -6,7 +6,7 @@ import {
Tag,
useDisclosure,
} from '@chakra-ui/react'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { GeneralSettings } from 'models'
@ -56,7 +56,7 @@ export const GeneralSettingsForm = ({
return (
<Stack spacing={6}>
<UpgradeModal isOpen={isOpen} onClose={onClose} />
<ChangePlanModal isOpen={isOpen} onClose={onClose} />
<Flex
justifyContent="space-between"
align="center"
@ -82,7 +82,11 @@ export const GeneralSettingsForm = ({
<SwitchWithLabel
id="new-result"
label="Remember session"
initialValue={isDefined(generalSettings.isNewResultOnRefreshEnabled) ? !generalSettings.isNewResultOnRefreshEnabled : true}
initialValue={
isDefined(generalSettings.isNewResultOnRefreshEnabled)
? !generalSettings.isNewResultOnRefreshEnabled
: true
}
onCheckChange={handleNewResultOnRefreshChange}
moreInfoContent="If the user refreshes the page, its existing results will be overwritten. Disable this if you want to create a new results every time the user refreshes the page."
/>

View File

@ -0,0 +1,108 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import { useToast } from '../hooks/useToast'
import { ProPlanContent } from './ProPlanContent'
import { pay } from './queries/updatePlan'
import { useCurrentSubscriptionInfo } from './queries/useCurrentSubscriptionInfo'
import { StarterPlanContent } from './StarterPlanContent'
export const ChangePlanForm = () => {
const { user } = useUser()
const { workspace, refreshWorkspace } = useWorkspace()
const { showToast } = useToast()
const { data, mutate: refreshCurrentSubscriptionInfo } =
useCurrentSubscriptionInfo({
stripeId: workspace?.stripeId,
plan: workspace?.plan,
})
const handlePayClick = async ({
plan,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
}: {
plan: 'STARTER' | 'PRO'
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => {
if (
!user ||
!workspace ||
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
await pay({
stripeId: workspace.stripeId ?? undefined,
user,
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
})
refreshCurrentSubscriptionInfo({
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace({
plan,
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,
})
}
return (
<Stack spacing={4}>
<HStack
alignItems="stretch"
spacing="4"
w="full"
pt={
workspace?.plan === Plan.STARTER || workspace?.plan === Plan.PRO
? '10'
: '0'
}
>
<StarterPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalStorageIndex : 0
}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
/>
<ProPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalChatsIndex : 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalStorageIndex : 0
}
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
/>
</HStack>
<Text color="gray.500">
Need custom limits? Specific features?{' '}
<NextChakraLink
href={'https://typebot.io/enterprise-lead-form'}
isExternal
textDecor="underline"
>
Let me know
</NextChakraLink>
.
</Text>
</Stack>
)
}

View File

@ -0,0 +1,339 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
Tooltip,
Flex,
Tag,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
} from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
import { FeaturesList } from './components/FeaturesList'
import { computePrice, formatPrice } from './helpers'
type ProPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}
export const ProPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
onPayClick,
}: ProPlanContentProps) => {
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const workspaceStorageLimit = workspace
? getStorageLimit(workspace)
: undefined
console.log('workspaceChatsLimit', workspaceChatsLimit)
console.log('workspaceStorageLimit', workspace)
const isCurrentPlan =
chatsLimit[Plan.PRO].totalIncluded +
chatsLimit[Plan.PRO].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.PRO].totalIncluded +
storageLimit[Plan.PRO].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
const getButtonLabel = () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return ''
if (workspace?.plan === Plan.PRO) {
if (isCurrentPlan) return 'Your current plan'
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
)
return 'Update'
}
return 'Upgrade'
}
const handlePayClick = async () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor="blue.500"
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
Most popular
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
Upgrade to <chakra.span color="blue.400">Pro</chakra.span>
</Heading>
<Text>For agencies & growing startups.</Text>
</Stack>
<Stack spacing="4">
<Heading>
{formatPrice(
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
'Branding removed',
'File upload input block',
'Create folders',
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
Everything in Starter
</chakra.span>
</Tooltip>
, plus:
</Text>
<FeaturesList
features={[
'5 seats included',
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
(selectedChatsLimitIndex ?? 0)
)}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
chats/mo
</Text>
<MoreInfoTooltip>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives.
</MoreInfoTooltip>
</HStack>,
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedStorageLimitIndex === undefined}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
(selectedStorageLimitIndex ?? 0)
)}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(0)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(1)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(2)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(3)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(4)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
GB of storage
</Text>
<MoreInfoTooltip>
You accumulate storage for every file that your user upload
into your bot. If you delete the result, it will free up the
space.
</MoreInfoTooltip>
</HStack>,
'Custom domains',
'In-depth analytics',
]}
/>
<Button
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
</Stack>
</Flex>
)
}

View File

@ -0,0 +1,280 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
} from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
import { FeaturesList } from './components/FeaturesList'
import { computePrice, formatPrice } from './helpers'
type StarterPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}
export const StarterPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
onPayClick,
}: StarterPlanContentProps) => {
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const workspaceStorageLimit = workspace
? getStorageLimit(workspace)
: undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].totalIncluded +
chatsLimit[Plan.STARTER].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.STARTER].totalIncluded +
storageLimit[Plan.STARTER].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
const getButtonLabel = () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return ''
if (workspace?.plan === Plan.PRO) return 'Downgrade'
if (workspace?.plan === Plan.STARTER) {
if (isCurrentPlan) return 'Your current plan'
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
)
return 'Update'
}
return 'Upgrade'
}
const handlePayClick = async () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
<Stack spacing="4">
<Heading fontSize="2xl">
Upgrade to <chakra.span color="orange.400">Starter</chakra.span>
</Heading>
<Text>For individuals & small businesses.</Text>
<Heading>
{formatPrice(
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<FeaturesList
features={[
'2 seats included',
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
(selectedChatsLimitIndex ?? 0)
)}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
chats/mo
</Text>
<MoreInfoTooltip>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives.
</MoreInfoTooltip>
</HStack>,
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedStorageLimitIndex === undefined}
>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
(selectedStorageLimitIndex ?? 0)
)}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
GB of storage
</Text>
<MoreInfoTooltip>
You accumulate storage for every file that your user upload into
your bot. If you delete the result, it will free up the space.
</MoreInfoTooltip>
</HStack>,
'Branding removed',
'File upload input block',
'Create folders',
]}
/>
</Stack>
<Button
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
)
}

View File

@ -0,0 +1,21 @@
import {
ListProps,
UnorderedList,
Flex,
ListItem,
ListIcon,
} from '@chakra-ui/react'
import { CheckIcon } from 'assets/icons'
type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat, idx) => (
<Flex as={ListItem} key={idx} alignItems="center">
<ListIcon as={CheckIcon} />
{feat}
</Flex>
))}
</UnorderedList>
)

View File

@ -0,0 +1,86 @@
import { Plan } from 'db'
import { chatsLimit, prices, storageLimit } from 'utils'
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
selectedTotalStorageIndex: number
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const {
increaseStep: { price: chatsPrice },
} = chatsLimit[plan]
const {
increaseStep: { price: storagePrice },
} = storageLimit[plan]
return (
prices[plan] +
selectedTotalChatsIndex * chatsPrice +
selectedTotalStorageIndex * storagePrice
)
}
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () =>
navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
export const formatPrice = (price: number) => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: isEuropean ? 'EUR' : 'USD',
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
})
return formatter.format(price)
}

View File

@ -0,0 +1 @@
export { ChangePlanForm } from './ChangePlanForm'

View File

@ -1,55 +1,61 @@
import { Plan, User } from 'db'
import { loadStripe } from '@stripe/stripe-js/pure'
import { Plan, User } from 'db'
import { env, isDefined, isEmpty, sendRequest } from 'utils'
import { guessIfUserIsEuropean } from '../helpers'
type Props = {
type UpgradeProps = {
user: User
customerId?: string
currency: 'usd' | 'eur'
plan: 'pro' | 'team'
stripeId?: string
plan: Plan
workspaceId: string
additionalChats: number
additionalStorage: number
}
export const pay = async ({
customerId,
stripeId,
...props
}: Props): Promise<{ newPlan: Plan } | undefined | void> =>
isDefined(customerId)
? updatePlan({ ...props, customerId })
}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> =>
isDefined(stripeId)
? updatePlan({ ...props, stripeId })
: redirectToCheckout(props)
const updatePlan = async ({
customerId,
export const updatePlan = async ({
stripeId,
plan,
workspaceId,
currency,
}: Omit<Props, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
const { data, error } = await sendRequest<{ message: string }>({
method: 'POST',
url: '/api/stripe/update-subscription',
body: { workspaceId, plan, customerId, currency },
method: 'PUT',
url: '/api/stripe/subscription',
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
})
if (error || !data) return
return { newPlan: plan === 'team' ? Plan.TEAM : Plan.PRO }
return { newPlan: plan }
}
const redirectToCheckout = async ({
export const redirectToCheckout = async ({
user,
currency,
plan,
workspaceId,
}: Omit<Props, 'customerId'>) => {
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'customerId'>) => {
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST',
url: '/api/stripe/checkout',
url: '/api/stripe/subscription',
body: {
email: user.email,
currency,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
plan,
workspaceId,
href: location.origin + location.pathname,
additionalChats,
additionalStorage,
},
})
if (error || !data) return

View File

@ -0,0 +1,30 @@
import { Plan } from 'db'
import { fetcher } from 'services/utils'
import useSWR from 'swr'
export const useCurrentSubscriptionInfo = ({
stripeId,
plan,
}: {
stripeId?: string | null
plan?: Plan
}) => {
const { data, mutate } = useSWR<
{
additionalChatsIndex: number
additionalStorageIndex: number
},
Error
>(
stripeId && (plan === Plan.STARTER || plan === Plan.PRO)
? `/api/stripe/subscription?stripeId=${stripeId}`
: null,
fetcher
)
return {
data: !stripeId
? { additionalChatsIndex: 0, additionalStorageIndex: 0 }
: data,
mutate,
}
}

View File

@ -7,10 +7,9 @@ import {
Text,
useDisclosure,
} from '@chakra-ui/react'
import { Plan } from 'db'
import React from 'react'
import { UpgradeModal } from './modals/UpgradeModal'
import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
import { ChangePlanModal } from './modals/ChangePlanModal'
import { LimitReached } from './modals/ChangePlanModal'
export const Info = (props: AlertProps) => (
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
@ -27,30 +26,34 @@ export const UnlockPlanInfo = ({
contentLabel,
buttonLabel = 'More info',
type,
plan = Plan.PRO,
...props
}: {
contentLabel: string
contentLabel: React.ReactNode
buttonLabel?: string
type?: LimitReached
plan: Plan
}) => {
} & AlertProps) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<Alert
status="info"
bgColor={'blue.50'}
rounded="md"
justifyContent="space-between"
flexShrink={0}
{...props}
>
<HStack>
<AlertIcon />
<Text>{contentLabel}</Text>
</HStack>
<Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
<Button
colorScheme={props.status === 'warning' ? 'orange' : 'blue'}
onClick={onOpen}
flexShrink={0}
ml="2"
>
{buttonLabel}
</Button>
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
</Alert>
)
}

View File

@ -8,7 +8,7 @@ type Props = {
export const MoreInfoTooltip = ({ children }: Props) => {
return (
<Tooltip label={children}>
<Tooltip label={children} hasArrow rounded="md" p="3">
<chakra.span cursor="pointer">
<HelpCircleIcon />
</chakra.span>

View File

@ -0,0 +1,30 @@
import { Tag } from '@chakra-ui/react'
import { Plan } from 'db'
export const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.LIFETIME:
case Plan.PRO: {
return (
<Tag colorScheme="blue" data-testid="plan-tag">
Pro
</Tag>
)
}
case Plan.OFFERED:
case Plan.STARTER: {
return (
<Tag colorScheme="orange" data-testid="plan-tag">
Starter
</Tag>
)
}
default: {
return (
<Tag colorScheme="gray" data-testid="plan-tag">
Free
</Tag>
)
}
}
}

View File

@ -15,14 +15,12 @@ import {
import { ChevronLeftIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import { InputBlockType } from 'models'
import { useRouter } from 'next/router'
import { timeSince } from 'services/utils'
import { isFreePlan } from 'services/workspace'
import { isNotDefined } from 'utils'
import { UpgradeModal } from '../modals/UpgradeModal'
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
import { LimitReached, ChangePlanModal } from '../modals/ChangePlanModal'
export const PublishButton = (props: ButtonProps) => {
const { workspace } = useWorkspace()
@ -50,8 +48,7 @@ export const PublishButton = (props: ButtonProps) => {
return (
<HStack spacing="1px">
<UpgradeModal
plan={Plan.PRO}
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={LimitReached.FILE_INPUT}

View File

@ -1,14 +1,13 @@
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
import { isNotDefined } from 'utils'
import { UpgradeModal } from '../modals/UpgradeModal'
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
import { ChangePlanModal } from '../modals/ChangePlanModal'
import { LimitReached } from '../modals/ChangePlanModal'
type Props = { plan?: Plan; type?: LimitReached } & ButtonProps
type Props = { type?: LimitReached } & ButtonProps
export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
export const UpgradeButton = ({ type, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
return (
@ -19,7 +18,7 @@ export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
onClick={onOpen}
>
{props.children ?? 'Upgrade'}
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
</Button>
)
}

View File

@ -29,7 +29,7 @@ export const UploadButton = ({
},
],
})
if (urls.length) onFileUploaded(urls[0])
if (urls.length && urls[0]) onFileUploaded(urls[0])
setIsUploading(false)
}

View File

@ -7,12 +7,14 @@ export const useToast = () => {
title,
description,
status = 'error',
...props
}: UseToastOptions) => {
toast({
position: 'bottom-right',
description,
title,
status,
...props,
})
}

View File

@ -0,0 +1,53 @@
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalOverlay,
Stack,
Button,
HStack,
} from '@chakra-ui/react'
import { Info } from 'components/shared/Info'
import { ChangePlanForm } from 'components/shared/ChangePlanForm'
export enum LimitReached {
BRAND = 'remove branding',
CUSTOM_DOMAIN = 'add custom domain',
FOLDER = 'create folders',
FILE_INPUT = 'use file input blocks',
}
type ChangePlanModalProps = {
type?: LimitReached
isOpen: boolean
onClose: () => void
}
export const ChangePlanModal = ({
onClose,
isOpen,
type,
}: ChangePlanModalProps) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalBody as={Stack} spacing="6" pt="10">
{type && (
<Info>You need to upgrade your plan in order to {type}</Info>
)}
<ChangePlanForm />
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@ -1,13 +0,0 @@
import { Button, ButtonProps } from '@chakra-ui/react'
import * as React from 'react'
export const ActionButton = (props: ButtonProps) => (
<Button
colorScheme="blue"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
{...props}
/>
)

View File

@ -1,28 +0,0 @@
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react'
import * as React from 'react'
import { CardBadge } from './CardBadge'
export interface CardProps extends BoxProps {
isPopular?: boolean
}
export const Card = (props: CardProps) => {
const { children, isPopular, ...rest } = props
return (
<Box
bg={useColorModeValue('white', 'gray.700')}
position="relative"
px="6"
pb="6"
pt="16"
overflow="hidden"
shadow="lg"
maxW="md"
width="100%"
{...rest}
>
{isPopular && <CardBadge>Popular</CardBadge>}
{children}
</Box>
)
}

View File

@ -1,30 +0,0 @@
import { Flex, FlexProps, Text, useColorModeValue } from '@chakra-ui/react'
import * as React from 'react'
export const CardBadge = (props: FlexProps) => {
const { children, ...flexProps } = props
return (
<Flex
bg={useColorModeValue('green.500', 'green.200')}
position="absolute"
right={-20}
top={6}
width="240px"
transform="rotate(45deg)"
py={2}
justifyContent="center"
alignItems="center"
{...flexProps}
>
<Text
fontSize="xs"
textTransform="uppercase"
fontWeight="bold"
letterSpacing="wider"
color={useColorModeValue('white', 'gray.800')}
>
{children}
</Text>
</Flex>
)
}

View File

@ -1,68 +0,0 @@
import {
Flex,
Heading,
List,
ListIcon,
ListItem,
Text,
useColorModeValue,
VStack,
} from '@chakra-ui/react'
import { CheckIcon } from 'assets/icons'
import * as React from 'react'
import { Card, CardProps } from './Card'
export interface PricingCardData {
features: string[]
name: string
price: string
}
interface PricingCardProps extends CardProps {
data: PricingCardData
button: React.ReactElement
}
export const PricingCard = (props: PricingCardProps) => {
const { data, button, ...rest } = props
const { features, price, name } = data
const accentColor = useColorModeValue('blue.500', 'blue.200')
return (
<Card rounded={{ sm: 'xl' }} {...rest}>
<VStack spacing={6}>
<Heading size="md" fontWeight="extrabold">
{name}
</Heading>
</VStack>
<Flex
align="flex-end"
justify="center"
fontWeight="extrabold"
color={accentColor}
my="8"
>
<Heading size="3xl" fontWeight="inherit" lineHeight="0.9em">
{price}
</Heading>
<Text fontWeight="inherit" fontSize="2xl">
/ mo
</Text>
</Flex>
<List spacing="4" mb="8" maxW="30ch" mx="auto">
{features.map((feature, index) => (
<ListItem fontWeight="medium" key={index}>
<ListIcon
fontSize="xl"
as={CheckIcon}
marginEnd={2}
color={accentColor}
/>
{feature}
</ListItem>
))}
</List>
{button}
</Card>
)
}

View File

@ -1,217 +0,0 @@
import { useEffect, useState } from 'react'
import {
Heading,
Modal,
ModalBody,
Text,
ModalContent,
ModalFooter,
ModalOverlay,
Stack,
ListItem,
UnorderedList,
ListIcon,
chakra,
Tooltip,
ListProps,
Button,
HStack,
} from '@chakra-ui/react'
import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext'
import { Plan } from 'db'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { TypebotLogo } from 'assets/logos'
import { CheckIcon } from 'assets/icons'
import { toTitleCase } from 'utils'
import { useToast } from 'components/shared/hooks/useToast'
import { Info } from 'components/shared/Info'
export enum LimitReached {
BRAND = 'remove branding',
CUSTOM_DOMAIN = 'add custom domain',
FOLDER = 'create folders',
FILE_INPUT = 'use file input blocks',
}
type UpgradeModalProps = {
type?: LimitReached
isOpen: boolean
onClose: () => void
plan?: Plan
}
export const UpgradeModal = ({
onClose,
isOpen,
type,
plan = Plan.PRO,
}: UpgradeModalProps) => {
const { user } = useUser()
const { workspace, refreshWorkspace } = useWorkspace()
const [payLoading, setPayLoading] = useState(false)
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
const { showToast } = useToast()
useEffect(() => {
setCurrency(
navigator.languages.find((l) => l.includes('fr')) ? 'eur' : 'usd'
)
}, [])
const handlePayClick = async () => {
if (!user || !workspace) return
setPayLoading(true)
const response = await pay({
customerId: workspace.stripeId ?? undefined,
user,
currency,
plan: plan === Plan.TEAM ? 'team' : 'pro',
workspaceId: workspace.id,
})
setPayLoading(false)
if (response?.newPlan) {
refreshWorkspace({ plan: response.newPlan })
showToast({
status: 'success',
title: 'Upgrade success!',
description: `Workspace successfully upgraded to ${toTitleCase(
response.newPlan
)} plan 🎉`,
})
}
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalBody as={Stack} pt="10">
{plan === Plan.PRO ? (
<PersonalProPlanContent currency={currency} type={type} />
) : (
<TeamPlanContent currency={currency} type={type} />
)}
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handlePayClick}
isLoading={payLoading}
colorScheme="blue"
>
Upgrade
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const PersonalProPlanContent = ({
currency,
type,
}: {
currency: 'eur' | 'usd'
type?: LimitReached
}) => {
return (
<Stack spacing="4">
<Info>You need to upgrade your plan in order to {type}</Info>
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="orange.400">Personal Pro</chakra.span>{' '}
plan
</Heading>
<Text>For solo creators who want to do even more.</Text>
<Heading>
{currency === 'eur' ? '39€' : '$39'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">Everything in Personal, plus:</Text>
<FeatureList
features={[
'Branding removed',
'View incomplete submissions',
'In-depth drop off analytics',
'Unlimited custom domains',
'Organize typebots in folders',
'Unlimited uploads',
]}
/>
</Stack>
)
}
const TeamPlanContent = ({
currency,
type,
}: {
currency: 'eur' | 'usd'
type?: LimitReached
}) => {
return (
<Stack spacing="4">
<Info>You need to upgrade your plan in order to {type}</Info>
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="purple.400">Team</chakra.span> plan
</Heading>
<Text>For teams to build typebots together in one spot.</Text>
<Heading>
{currency === 'eur' ? '99€' : '$99'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeatureList
features={[
'Branding removed',
'View incomplete submissions',
'In-depth drop off analytics',
'Custom domains',
'Organize typebots in folders',
'Unlimited uploads',
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
Everything in Pro
</chakra.span>
</Tooltip>
, plus:
</Text>
<FeatureList
features={[
'Unlimited team members',
'Collaborative workspace',
'Custom roles',
]}
/>
</Stack>
)
}
const FeatureList = ({
features,
...props
}: { features: string[] } & ListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat) => (
<ListItem key={feat}>
<ListIcon as={CheckIcon} />
{feat}
</ListItem>
))}
</UnorderedList>
)

View File

@ -1 +0,0 @@
export { UpgradeModal } from './UpgradeModal'

View File

@ -0,0 +1 @@
export { ChangePlanModal } from './ChangePlanModal'

View File

@ -15,7 +15,6 @@ const resultsContext = createContext<{
hasMore: boolean
resultHeader: ResultHeaderCell[]
totalResults: number
totalHiddenResults?: number
tableData: TableData[]
onDeleteResults: (totalResultsDeleted: number) => void
fetchMore: () => void
@ -33,14 +32,12 @@ export const ResultsProvider = ({
workspaceId,
typebotId,
totalResults,
totalHiddenResults,
onDeleteResults,
}: {
children: ReactNode
workspaceId: string
typebotId: string
totalResults: number
totalHiddenResults?: number
onDeleteResults: (totalResultsDeleted: number) => void
}) => {
const { publishedTypebot, linkedTypebots } = useTypebot()
@ -84,7 +81,6 @@ export const ResultsProvider = ({
tableData,
resultHeader,
totalResults,
totalHiddenResults,
onDeleteResults,
fetchMore,
mutate,

View File

@ -98,6 +98,12 @@ const components = {
},
},
},
Tooltip: {
defaultProps: {
rounded: 'md',
hasArrow: true,
},
},
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -9,7 +9,7 @@
"start": "next start",
"lint": "next lint",
"test": "pnpm playwright test",
"test:open": "PWDEBUG=1 pnpm playwright test"
"test:open": "NO_RETRIES=1 pnpm playwright test --debug"
},
"dependencies": {
"@chakra-ui/css-reset": "2.0.7",

View File

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-sync-scripts */
import Document, {
Html,
Head,
@ -22,7 +23,6 @@ class MyDocument extends Document {
rel="stylesheet"
/>
<meta name="google" content="notranslate" />
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script src="/__env.js" />
</Head>
<body>

View File

@ -12,6 +12,7 @@ import { withSentry } from '@sentry/nextjs'
import { CustomAdapter } from './adapter'
import { User } from 'db'
import { env, isNotEmpty } from 'utils'
import { mockedUser } from 'services/api/utils'
const providers: Provider[] = []
@ -98,6 +99,14 @@ if (
}
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (
req.method === 'GET' &&
req.url === '/api/auth/session' &&
env('E2E_TEST') === 'true'
) {
res.send({ user: mockedUser })
return
}
if (req.method === 'HEAD') {
res.status(200)
return

View File

@ -52,10 +52,11 @@ export function CustomAdapter(p: PrismaClient): Adapter {
name: data.name
? `${data.name}'s workspace`
: `My workspace`,
plan:
process.env.ADMIN_EMAIL === data.email
? Plan.TEAM
: Plan.FREE,
...(process.env.ADMIN_EMAIL === data.email
? { plan: Plan.LIFETIME }
: {
plan: Plan.FREE,
}),
},
},
},

View File

@ -15,13 +15,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string | undefined
if (!workspaceId) return badRequest(res)
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
stripeId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})

View File

@ -1,46 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const { email, currency, plan, workspaceId, href } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const session = await stripe.checkout.sessions.create({
success_url: `${href}?stripe=${plan}`,
cancel_url: `${href}?stripe=cancel`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer_email: email,
mode: 'subscription',
metadata: { workspaceId, plan },
line_items: [
{
price: getPrice(plan, currency),
quantity: 1,
},
],
})
return res.status(201).send({ sessionId: session.id })
}
return methodNotAllowed(res)
}
export const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
if (plan === 'team')
return currency === 'eur'
? process.env.STRIPE_PRICE_TEAM_EUR_ID
: process.env.STRIPE_PRICE_TEAM_USD_ID
return currency === 'eur'
? process.env.STRIPE_PRICE_EUR_ID
: process.env.STRIPE_PRICE_USD_ID
}
export default withSentry(handler)

View File

@ -0,0 +1,49 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
forbidden,
methodNotAllowed,
notAuthenticated,
} from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedUser } from 'services/api/utils'
import prisma from 'libs/prisma'
import { WorkspaceRole } from 'db'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const invoices = await stripe.invoices.list({
customer: workspace.stripeId,
})
res.send({
invoices: invoices.data.map((i) => ({
id: i.number,
url: i.invoice_pdf,
amount: i.subtotal,
currency: i.currency,
date: i.status_transitions.paid_at,
})),
})
return
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,240 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
forbidden,
isDefined,
methodNotAllowed,
notAuthenticated,
} from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedUser } from 'services/api/utils'
import prisma from 'libs/prisma'
import { Plan, WorkspaceRole } from 'db'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET')
return res.send(await getSubscriptionDetails(req, res)(user.id))
if (req.method === 'POST') {
const session = await createCheckoutSession(req)
return res.send({ sessionId: session.id })
}
if (req.method === 'PUT') {
await updateSubscription(req)
return res.send({ message: 'success' })
}
if (req.method === 'DELETE') {
await cancelSubscription(req, res)(user.id)
return res.send({ message: 'success' })
}
return methodNotAllowed(res)
}
const getSubscriptionDetails =
(req: NextApiRequest, res: NextApiResponse) => async (userId: string) => {
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
limit: 1,
})
return {
additionalChatsIndex:
subscriptions.data[0].items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0,
additionalStorageIndex:
subscriptions.data[0].items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0,
}
}
const createCheckoutSession = (req: NextApiRequest) => {
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const {
email,
currency,
plan,
workspaceId,
href,
additionalChats,
additionalStorage,
} = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
return stripe.checkout.sessions.create({
success_url: `${href}?stripe=${plan}&success=true`,
cancel_url: `${href}?stripe=cancel`,
allow_promotion_codes: true,
customer_email: email,
mode: 'subscription',
metadata: { workspaceId, plan, additionalChats, additionalStorage },
currency,
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(
plan,
additionalChats,
additionalStorage
),
})
}
const updateSubscription = async (req: NextApiRequest) => {
const { customerId, plan, workspaceId, additionalChats, additionalStorage } =
(typeof req.body === 'string' ? JSON.parse(req.body) : req.body) as {
customerId: string
workspaceId: string
additionalChats: number
additionalStorage: number
plan: 'STARTER' | 'PRO'
}
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const { data } = await stripe.subscriptions.list({
customer: customerId,
})
const subscription = data[0]
const currentStarterPlanItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id
const currentProPlanItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
)?.id
const currentAdditionalChatsItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.id
const currentAdditionalStorageItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.id
const items = [
{
id: currentStarterPlanItemId ?? currentProPlanItemId,
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
currentAdditionalChatsItemId
? {
id: currentAdditionalChatsItemId,
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
deleted: additionalChats === 0,
}
: undefined,
currentAdditionalStorageItemId
? {
id: currentAdditionalStorageItemId,
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
deleted: additionalStorage === 0,
}
: undefined,
].filter(isDefined)
console.log(items)
await stripe.subscriptions.update(subscription.id, {
items,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
additionalStorageIndex: additionalStorage,
},
})
}
const cancelSubscription =
(req: NextApiRequest, res: NextApiResponse) => async (userId: string) => {
console.log(req.query.stripeId, userId)
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const existingSubscription = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
console.log('yes')
await stripe.subscriptions.del(existingSubscription.data[0].id)
console.log('deleted')
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
},
})
}
const parseSubscriptionItems = (
plan: Plan,
additionalChats: number,
additionalStorage: number
) =>
[
{
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
]
.concat(
additionalChats > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
},
]
: []
)
.concat(
additionalStorage > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
},
]
: []
)
export default withSentry(handler)

View File

@ -1,46 +0,0 @@
import { withSentry } from '@sentry/nextjs'
import { Plan } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import { badRequest, methodNotAllowed } from 'utils'
import { getPrice } from './checkout'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { customerId, currency, plan, workspaceId } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
})
const { id, items } = subscriptions.data[0]
const newPrice = getPrice(plan, currency)
const oldPrice = subscriptions.data[0].items.data[0].price.id
if (newPrice === oldPrice) return badRequest(res)
await stripe.subscriptions.update(id, {
cancel_at_period_end: false,
proration_behavior: 'create_prorations',
items: [
{
id: items.data[0].id,
price: getPrice(plan, currency),
},
],
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: plan === 'team' ? Plan.TEAM : Plan.PRO,
},
})
return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -4,7 +4,6 @@ import Stripe from 'stripe'
import Cors from 'micro-cors'
import { buffer } from 'micro'
import prisma from 'libs/prisma'
import { Plan } from 'db'
import { withSentry } from '@sentry/nextjs'
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
@ -40,30 +39,29 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const { metadata } = session
if (!metadata?.workspaceId || !metadata?.plan)
return res.status(500).send({ message: `customer_email not found` })
const { workspaceId, plan, additionalChats, additionalStorage } =
session.metadata as unknown as {
plan: 'STARTER' | 'PRO'
additionalChats: string
additionalStorage: string
workspaceId: string
}
if (!workspaceId || !plan || !additionalChats || !additionalStorage)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
await prisma.workspace.update({
where: { id: metadata.workspaceId },
where: { id: workspaceId },
data: {
plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO,
plan: plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
additionalStorageIndex: parseInt(additionalStorage),
},
})
return res.status(200).send({ message: 'workspace upgraded in DB' })
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await prisma.workspace.update({
where: {
stripeId: subscription.customer as string,
},
data: {
plan: Plan.FREE,
},
})
return res.send({ message: 'workspace downgraded in DB' })
}
default: {
return res.status(304).send({ message: 'event not handled' })
}

View File

@ -4,7 +4,7 @@ import { CollaborationType, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
import { sendEmailNotification } from 'services/api/emails'
import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
import {
badRequest,
@ -29,6 +29,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const typebot = await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
include: { workspace: { select: { name: true } } },
})
if (!typebot || !typebot.workspaceId) return forbidden(res)
const { email, type } =
@ -70,10 +71,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await sendEmailNotification({
to: email,
subject: "You've been invited to collaborate 🤝",
content: invitationToCollaborate(
user.email ?? '',
`${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`
),
html: invitationToCollaborate({
hostEmail: user.email ?? '',
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`,
guestEmail: email.toLowerCase(),
typebotName: typebot.name,
workspaceName: typebot.workspace?.name ?? '',
}),
})
return res.send({
message: 'success',

View File

@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
import { Workspace } from 'db'
import { Plan, Workspace } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
@ -22,7 +22,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
data: {
...data,
members: { create: [{ role: 'ADMIN', userId: user.id }] },
plan: process.env.ADMIN_EMAIL === user.email ? 'TEAM' : 'FREE',
plan:
process.env.ADMIN_EMAIL === user.email ? Plan.LIFETIME : Plan.FREE,
},
})
return res.status(200).json({

View File

@ -1,9 +1,17 @@
import { withSentry } from '@sentry/nextjs'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import { workspaceMemberInvitationEmail } from 'assets/emails/workspaceMemberInvitation'
import { Workspace, WorkspaceInvitation, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
import {
env,
forbidden,
methodNotAllowed,
notAuthenticated,
seatsLimit,
} from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
@ -20,6 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
},
})
if (!workspace) return forbidden(res)
if (await checkIfSeatsLimitReached(workspace))
return res.status(400).send('Seats limit reached')
if (existingUser) {
await prisma.memberInWorkspace.create({
data: {
@ -37,11 +48,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
workspaceId: data.workspaceId,
},
})
}
const invitation = await prisma.workspaceInvitation.create({ data })
return res.send({ invitation })
} else await prisma.workspaceInvitation.create({ data })
if (env('E2E_TEST') !== 'true')
await sendEmailNotification({
to: data.email,
subject: "You've been invited to collaborate 🤝",
html: workspaceMemberInvitationEmail({
workspaceName: workspace.name,
guestEmail: data.email,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`,
hostEmail: user.email ?? '',
}),
})
return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
const checkIfSeatsLimitReached = async (workspace: Workspace) => {
const existingMembersCount = await prisma.memberInWorkspace.count({
where: { workspaceId: workspace.id },
})
return existingMembersCount >= seatsLimit[workspace.plan].totalIncluded
}
export default withSentry(handler)

View File

@ -0,0 +1,54 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const totalChatsUsed = await prisma.result.count({
where: {
typebot: {
workspace: {
id: workspaceId,
members: { some: { userId: user.id } },
},
},
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
lte: lastDayOfMonth,
},
},
})
const {
_sum: { storageUsed: totalStorageUsed },
} = await prisma.answer.aggregate({
where: {
storageUsed: { gt: 0 },
result: {
typebot: {
workspace: {
id: workspaceId,
members: { some: { userId: user.id } },
},
},
},
},
_sum: { storageUsed: true },
})
return res.send({
totalChatsUsed,
totalStorageUsed,
})
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -5,10 +5,11 @@ import { FolderContent } from 'components/dashboard/FolderContent'
import { TypebotDndContext } from 'contexts/TypebotDndContext'
import { useRouter } from 'next/router'
import { Spinner, Stack, Text, VStack } from '@chakra-ui/react'
import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext'
import { NextPageContext } from 'next/types'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import { pay } from 'components/shared/ChangePlanForm/queries/updatePlan'
const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false)
@ -17,16 +18,15 @@ const DashboardPage = () => {
const { workspace } = useWorkspace()
useEffect(() => {
const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
const subscribePlan = query.subscribePlan as 'pro' | 'starter' | undefined
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
pay({
user,
plan: subscribePlan,
plan: subscribePlan === 'pro' ? Plan.PRO : Plan.STARTER,
workspaceId: workspace.id,
currency: navigator.languages.find((l) => l.includes('fr'))
? 'eur'
: 'usd',
additionalChats: 0,
additionalStorage: 0,
})
}
}, [query, user, workspace])

View File

@ -11,6 +11,12 @@ import { useRouter } from 'next/router'
import { useStats } from 'services/analytics'
import { useToast } from 'components/shared/hooks/useToast'
import { ResultsProvider } from 'contexts/ResultsProvider'
import { UnlockPlanInfo } from 'components/shared/Info'
import { getChatsLimit, getStorageLimit } from 'utils'
import { useUsage } from 'components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/useUsage'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
const ResultsPage = () => {
const router = useRouter()
@ -26,6 +32,45 @@ const ResultsPage = () => {
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const { data: usageData } = useUsage(workspace?.id)
console.log(workspace?.id, usageData)
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.plan,
])
const storageLimitPercentage = useMemo(() => {
console.log(usageData?.totalStorageUsed)
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalStorageUsed /
1024 /
1024 /
1024 /
getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan,
})) *
100
)
}, [
usageData?.totalStorageUsed,
workspace?.additionalStorageIndex,
workspace?.plan,
])
const handleDeletedResults = (total: number) => {
if (!stats) return
@ -40,6 +85,38 @@ const ResultsPage = () => {
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
/>
<TypebotHeader />
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{chatsLimitPercentage}%</strong> of your total chats
limit this month. Upgrade your plan to continue chatting with
your customers beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
{storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{storageLimitPercentage}%</strong> of your total storage
allowed. Upgrade your plan or delete some existing results to
continue collecting files from your user beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
<Flex h="full" w="full">
<Flex
pos="absolute"

View File

@ -9,10 +9,6 @@ require('dotenv').config({
const config: PlaywrightTestConfig = {
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
testDir: path.join(__dirname, 'playwright/tests'),
timeout: 10 * 2000,
expect: {
timeout: 5000,
},
retries: process.env.NO_RETRIES ? 0 : 2,
workers: process.env.CI ? 1 : 3,
reporter: 'html',
@ -21,7 +17,7 @@ const config: PlaywrightTestConfig = {
actionTimeout: 0,
baseURL: process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL,
trace: 'on-first-retry',
storageState: path.join(__dirname, 'playwright/proUser.json'),
storageState: path.join(__dirname, 'playwright/firstUser.json'),
video: 'retain-on-failure',
locale: 'en-US',
},

View File

@ -0,0 +1,15 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "typebot-20-modal",
"value": "hide"
},
{ "name": "workspaceId", "value": "proWorkspace" }
]
}
]
}

View File

@ -1,19 +0,0 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "authenticatedUser",
"value": "{\"id\":\"freeUser\",\"name\":\"Free user\",\"email\":\"free-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"FREE\",\"stripeId\":null,\"graphNavigation\": \"TRACKPAD\"}"
},
{
"name": "typebot-20-modal",
"value": "hide"
},
{ "name": "workspaceId", "value": "freeWorkspace" }
]
}
]
}

View File

@ -1,18 +0,0 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "authenticatedUser",
"value": "{\"id\":\"proUser\",\"name\":\"Pro user\",\"email\":\"pro-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null,\"graphNavigation\": \"TRACKPAD\"}"
},
{
"name": "typebot-20-modal",
"value": "hide"
}
]
}
]
}

View File

@ -0,0 +1,15 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "typebot-20-modal",
"value": "hide"
},
{ "name": "workspaceId", "value": "freeWorkspace" }
]
}
]
}

View File

@ -6,12 +6,12 @@ export const refreshUser = async () => {
document.dispatchEvent(event)
}
export const mockSessionApiCalls = (page: Page) =>
export const connectedAsOtherUser = async (page: Page) =>
page.route('/api/auth/session', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
body: '{"user":{"id":"proUser","name":"Pro user","email":"pro-user@email.com","emailVerified":null,"image":"https://avatars.githubusercontent.com/u/16015833?v=4","stripeId":null,"graphNavigation": "TRACKPAD"}}',
body: '{"user":{"id":"otherUserId","name":"James Doe","email":"other-user@email.com","emailVerified":null,"image":"https://avatars.githubusercontent.com/u/16015833?v=4","stripeId":null,"graphNavigation": "TRACKPAD"}}',
})
}
return route.continue()

View File

@ -15,52 +15,129 @@ import {
PrismaClient,
User,
WorkspaceRole,
Workspace,
} from 'db'
import { readFileSync } from 'fs'
import { encrypt } from 'utils'
import { encrypt, createFakeResults } from 'utils'
import Stripe from 'stripe'
const prisma = new PrismaClient()
const proWorkspaceId = 'proWorkspace'
const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY ?? '', {
apiVersion: '2022-08-01',
})
const userId = 'userId'
const otherUserId = 'otherUserId'
export const freeWorkspaceId = 'freeWorkspace'
export const sharedWorkspaceId = 'sharedWorkspace'
export const guestWorkspaceId = 'guestWorkspace'
export const starterWorkspaceId = 'starterWorkspace'
export const proWorkspaceId = 'proWorkspace'
const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
export const teardownDatabase = async () => {
const ownerFilter = {
where: {
workspace: {
members: { some: { userId: { in: ['freeUser', 'proUser'] } } },
},
},
}
await prisma.workspace.deleteMany({
where: {
members: {
some: { userId: { in: ['freeUser', 'proUser'] } },
some: { userId: { in: [userId, otherUserId] } },
},
},
})
await prisma.user.deleteMany({
where: { id: { in: ['freeUser', 'proUser'] } },
where: { id: { in: [userId, otherUserId] } },
})
return prisma.webhook.deleteMany()
}
export const addSubscriptionToWorkspace = async (
workspaceId: string,
items: Stripe.SubscriptionCreateParams.Item[],
metadata: Pick<
Workspace,
'additionalChatsIndex' | 'additionalStorageIndex' | 'plan'
>
) => {
const { id: stripeId } = await stripe.customers.create({
email: 'test-user@gmail.com',
name: 'Test User',
})
const { id: paymentId } = await stripe.paymentMethods.create({
card: {
number: '4242424242424242',
exp_month: 12,
exp_year: 2022,
cvc: '123',
},
type: 'card',
})
await stripe.paymentMethods.attach(paymentId, { customer: stripeId })
await stripe.subscriptions.create({
customer: stripeId,
items,
default_payment_method: paymentId,
currency: 'usd',
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
stripeId,
...metadata,
},
})
await prisma.webhook.deleteMany()
await prisma.credentials.deleteMany(ownerFilter)
await prisma.dashboardFolder.deleteMany(ownerFilter)
return prisma.typebot.deleteMany(ownerFilter)
}
export const setupDatabase = async () => {
await createWorkspaces()
await createUsers()
return createCredentials()
}
export const createWorkspaces = async () =>
prisma.workspace.createMany({
data: [
{
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
{
id: starterWorkspaceId,
name: 'Starter workspace',
stripeId: 'cus_LnPDugJfa18N41',
plan: Plan.STARTER,
},
{
id: proWorkspaceId,
name: 'Pro workspace',
plan: Plan.PRO,
},
{
id: lifetimeWorkspaceId,
name: 'Lifetime workspace',
plan: Plan.LIFETIME,
},
],
})
export const createWorkspace = async (workspace: Partial<Workspace>) => {
const { id: workspaceId } = await prisma.workspace.create({
data: {
name: 'Free workspace',
plan: Plan.FREE,
...workspace,
},
})
await prisma.memberInWorkspace.create({
data: { userId, workspaceId, role: WorkspaceRole.ADMIN },
})
return workspaceId
}
export const createUsers = async () => {
await prisma.user.create({
data: {
id: 'proUser',
email: 'pro-user@email.com',
name: 'Pro user',
id: userId,
email: 'user@email.com',
name: 'John Doe',
graphNavigation: GraphNavigation.TRACKPAD,
apiTokens: {
createMany: {
@ -83,69 +160,34 @@ export const createUsers = async () => {
],
},
},
workspaces: {
create: {
role: WorkspaceRole.ADMIN,
workspace: {
create: {
id: proWorkspaceId,
name: "Pro user's workspace",
plan: Plan.TEAM,
},
},
},
},
},
})
await prisma.user.create({
data: {
id: 'freeUser',
email: 'free-user@email.com',
name: 'Free user',
graphNavigation: GraphNavigation.TRACKPAD,
workspaces: {
create: {
role: WorkspaceRole.ADMIN,
workspace: {
create: {
id: 'free',
name: "Free user's workspace",
plan: Plan.FREE,
},
},
},
},
},
data: { id: otherUserId, email: 'other-user@email.com', name: 'James Doe' },
})
await prisma.workspace.create({
data: {
id: freeWorkspaceId,
name: 'Free Shared workspace',
plan: Plan.FREE,
members: {
createMany: {
data: [
{ role: WorkspaceRole.MEMBER, userId: 'proUser' },
{ role: WorkspaceRole.ADMIN, userId: 'freeUser' },
],
},
return prisma.memberInWorkspace.createMany({
data: [
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: freeWorkspaceId,
},
},
})
return prisma.workspace.create({
data: {
id: sharedWorkspaceId,
name: 'Shared workspace',
plan: Plan.TEAM,
members: {
createMany: {
data: [
{ role: WorkspaceRole.MEMBER, userId: 'proUser' },
{ role: WorkspaceRole.ADMIN, userId: 'freeUser' },
],
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: starterWorkspaceId,
},
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: proWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: lifetimeWorkspaceId,
},
],
})
}
@ -173,12 +215,12 @@ export const getSignedInUser = (email: string) =>
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
await prisma.typebot.createMany({
data: partialTypebots.map(parseTestTypebot) as any[],
data: partialTypebots.map(parseTestTypebot),
})
return prisma.publicTypebot.createMany({
data: partialTypebots.map((t) =>
parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t))
) as any[],
),
})
}
@ -217,43 +259,11 @@ export const updateUser = (data: Partial<User>) =>
prisma.user.update({
data,
where: {
id: 'proUser',
id: userId,
},
})
export const createResults = async ({ typebotId }: { typebotId: string }) => {
await prisma.result.deleteMany()
await prisma.result.createMany({
data: [
...Array.from(Array(200)).map((_, idx) => {
const today = new Date()
const rand = Math.random()
return {
id: `result${idx}`,
typebotId,
createdAt: new Date(
today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
),
isCompleted: rand > 0.5,
}
}),
],
})
return createAnswers()
}
const createAnswers = () => {
return prisma.answer.createMany({
data: [
...Array.from(Array(200)).map((_, idx) => ({
resultId: `result${idx}`,
content: `content${idx}`,
blockId: 'block1',
groupId: 'block1',
})),
],
})
}
export const createResults = createFakeResults(prisma)
export const createFolder = (workspaceId: string, name: string) =>
prisma.dashboardFolder.create({
@ -352,6 +362,6 @@ export const importTypebotInDatabase = async (
data: parseTypebotToPublicTypebot(
updates?.id ? `${updates?.id}-public` : 'publicBot',
typebot
) as any,
),
})
}

View File

@ -1,10 +1,6 @@
import test, { expect } from '@playwright/test'
import path from 'path'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
// Can't test the update features because of the auth mocking.
test('should display user info properly', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')

View File

@ -0,0 +1,175 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { Plan } from 'db'
import {
addSubscriptionToWorkspace,
createResults,
createTypebots,
createWorkspace,
starterWorkspaceId,
} from '../services/database'
test('should display valid usage', async ({ page }) => {
const starterTypebotId = cuid()
createTypebots([{ id: starterTypebotId, workspaceId: starterWorkspaceId }])
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 10,000"')).toBeVisible()
await expect(page.locator('text="/ 10 GB"')).toBeVisible()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text="Free workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible()
await page.click('text=Free workspace', { force: true })
await createResults({
idPrefix: 'usage',
count: 10,
typebotId: starterTypebotId,
isChronological: false,
fakeStorage: 1100 * 1024 * 1024,
})
await page.click('text=Free workspace')
await page.click('text="Starter workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('text="1.07 GB"')).toBeVisible()
await expect(page.locator('text="200"')).toBeVisible()
await expect(page.locator('[role="progressbar"] >> nth=0')).toHaveAttribute(
'aria-valuenow',
'10'
)
await expect(page.locator('[role="progressbar"] >> nth=1')).toHaveAttribute(
'aria-valuenow',
'54'
)
await createResults({
idPrefix: 'usage2',
typebotId: starterTypebotId,
isChronological: false,
count: 900,
fakeStorage: 1200 * 1024 * 1024,
})
await page.click('text="Settings"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="1,100"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('text="2.25 GB"')).toBeVisible()
await expect(page.locator('[aria-valuenow="55"]')).toBeVisible()
await expect(page.locator('[aria-valuenow="112"]')).toBeVisible()
})
test('plan changes should work', async ({ page }) => {
const workspaceId = await createWorkspace({ name: 'Awesome workspace' })
// Upgrade to STARTER
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Awesome workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com')
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=user@email.com')).toBeVisible()
await addSubscriptionToWorkspace(
workspaceId,
[
{
price: process.env.STRIPE_STARTER_PRICE_ID,
quantity: 1,
},
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: 3,
},
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: 2,
},
],
{ plan: Plan.STARTER, additionalChatsIndex: 3, additionalStorageIndex: 2 }
)
// Update plan with additional quotas
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.locator('text="/ 4 GB"')).toBeVisible()
await expect(page.locator('button >> text="3,500"')).toBeVisible()
await expect(page.locator('button >> text="4"')).toBeVisible()
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text="3,500"')
await page.click('button >> text="2,000"')
await page.click('button >> text="4"')
await page.click('button >> text="6"')
await expect(page.locator('text="$47"')).toBeVisible()
await page.click('button >> text=Update')
await expect(
page.locator(
'text="Workspace STARTER plan successfully updated 🎉" >> nth=0'
)
).toBeVisible()
// Upgrade to PRO
await page.click('button >> text="10,000"')
await page.click('button >> text="14,000"')
await page.click('button >> text="10"')
await page.click('button >> text="12"')
await expect(page.locator('text="$133"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible()
// Go to customer portal
await Promise.all([
page.waitForNavigation(),
page.click('text="Billing Portal"'),
])
await expect(page.locator('text="Add payment method"')).toBeVisible()
// Cancel subscription
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('[data-testid="plan-tag"]')).toHaveText('Pro')
await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="plan-tag"]')).toHaveText('Free')
})
test('should display invoices', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(
page.locator('text="No invoices found for this workspace."')
).toBeVisible()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text=Starter workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('text="Wed Jun 01 2022"')).toBeVisible()
await expect(page.locator('text="74567541-0001"')).toBeVisible()
await expect(page.locator('text="€30.00" >> nth=0')).toBeVisible()
})

View File

@ -6,13 +6,10 @@ import {
import { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
const siteSrc = 'https://app.cal.com/baptistearno/15min'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Embed bubble block', () => {
test.describe('Content settings', () => {
test('should import and parse embed correctly', async ({ page }) => {

View File

@ -7,13 +7,10 @@ import { BubbleBlockType, defaultImageBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import path from 'path'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const unsplashImageSrc =
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Image bubble block', () => {
test.describe('Content settings', () => {
test('should upload image file correctly', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Text bubble block', () => {
test('rich text features should work', async ({ page }) => {

View File

@ -10,15 +10,12 @@ import {
} from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const videoSrc =
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const vimeoVideoSrc = 'https://vimeo.com/649301125'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Video bubble block', () => {
test.describe('Content settings', () => {
test('should import video url correctly', async ({ page }) => {

View File

@ -3,7 +3,6 @@ import cuid from 'cuid'
import { CollaborationType, Plan, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { InputBlockType, defaultTextInputOptions } from 'models'
import { mockSessionApiCalls } from 'playwright/services/browser'
import {
createFolder,
createResults,
@ -11,8 +10,6 @@ import {
parseDefaultGroupWithBlock,
} from '../services/database'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Typebot owner', () => {
test('Can invite collaborators', async ({ page }) => {
const typebotId = cuid()
@ -101,7 +98,7 @@ test.describe('Collaborator', () => {
},
})
await createFolder(guestWorkspaceId, 'Guest folder')
await createResults({ typebotId })
await createResults({ typebotId, count: 10 })
await page.goto(`/typebots`)
await page.click("text=Pro user's workspace")
await page.click('text=Guest workspace #2')

View File

@ -2,14 +2,10 @@ import test, { expect } from '@playwright/test'
import { InputBlockType, defaultTextInputOptions } from 'models'
import {
createTypebots,
freeWorkspaceId,
parseDefaultGroupWithBlock,
starterWorkspaceId,
} from '../services/database'
import path from 'path'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test('should be able to connect custom domain', async ({ page }) => {
const typebotId = cuid()
@ -47,16 +43,13 @@ test('should be able to connect custom domain', async ({ page }) => {
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
})
test.describe('Free workspace', () => {
test.use({
storageState: path.join(__dirname, '../freeUser.json'),
})
test.describe('Starter workspace', () => {
test("Add my domain shouldn't be available", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
workspaceId: freeWorkspaceId,
workspaceId: starterWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,

View File

@ -1,12 +1,9 @@
import test, { expect, Page } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
import { mockSessionApiCalls } from 'playwright/services/browser'
import { createFolders, createTypebots } from '../services/database'
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Dashboard page', () => {
test('folders navigation should work', async ({ page }) => {
await page.goto('/typebots')
@ -79,7 +76,7 @@ test.describe('Dashboard page', () => {
test.describe('Free user', () => {
test.use({
storageState: path.join(__dirname, '../freeUser.json'),
storageState: path.join(__dirname, '../secondUser.json'),
})
test("create folder shouldn't be available", async ({ page }) => {
await page.goto('/typebots')

View File

@ -8,9 +8,6 @@ import { defaultTextInputOptions, InputBlockType } from 'models'
import path from 'path'
import cuid from 'cuid'
import { typebotViewer } from '../services/selectorUtils'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Editor', () => {
test('Edges connection should work', async ({ page }) => {

View File

@ -8,9 +8,6 @@ import { defaultChoiceInputOptions, InputBlockType, ItemType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import path from 'path'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Buttons input block', () => {
test('can edit button items', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { defaultDateInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Date input block', () => {
test('options should work', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { defaultEmailInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Email input block', () => {
test('options should work', async ({ page }) => {

View File

@ -8,9 +8,6 @@ import { defaultFileInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import path from 'path'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.configure({ mode: 'parallel' })
@ -61,9 +58,6 @@ test('options should work', async ({ page }) => {
})
test.describe('Free workspace', () => {
test.use({
storageState: path.join(__dirname, '../../freeUser.json'),
})
test("shouldn't be able to publish typebot", async ({ page }) => {
const typebotId = cuid()
await createTypebots([

View File

@ -6,9 +6,6 @@ import {
import { defaultNumberInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Number input block', () => {
test('options should work', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { defaultPaymentInputOptions, InputBlockType } from 'models'
import cuid from 'cuid'
import { stripePaymentForm, typebotViewer } from '../../services/selectorUtils'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Payment input block', () => {
test('Can configure Stripe account', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { defaultPhoneInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Phone input block', () => {
test('options should work', async ({ page }) => {

View File

@ -6,7 +6,6 @@ import {
import { defaultRatingInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const boxSvg = `<svg
xmlns="http://www.w3.org/2000/svg"
@ -21,8 +20,6 @@ const boxSvg = `<svg
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>`
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([

View File

@ -6,9 +6,6 @@ import {
import { defaultTextInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Text input block', () => {
test('options should work', async ({ page }) => {

View File

@ -6,9 +6,6 @@ import {
import { defaultUrlInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Url input block', () => {
test('options should work', async ({ page }) => {

View File

@ -5,9 +5,6 @@ import {
} from '../../services/database'
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Google Analytics block', () => {
test('its configuration should work', async ({ page }) => {

View File

@ -3,9 +3,6 @@ import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Google sheets integration', () => {
test('Insert row should work', async ({ page }) => {

View File

@ -3,12 +3,9 @@ import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Send email block', () => {
test('its configuration should work', async ({ page }) => {
if (

View File

@ -3,9 +3,6 @@ import { createWebhook, importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { HttpMethod } from 'models'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Webhook block', () => {
test('easy configuration should work', async ({ page }) => {

View File

@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Code block', () => {
test('code should trigger', async ({ page }) => {
await importTypebotInDatabase(

View File

@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Condition block', () => {
test('its configuration should work', async ({ page }) => {
await importTypebotInDatabase(

Some files were not shown because too many files have changed in this diff Show More