✨ Add usage-based new pricing plans
This commit is contained in:
committed by
Baptiste Arnaud
parent
6a1eaea700
commit
898367a33b
@ -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
37
apps/builder/assets/emails/workspaceMemberInvitation.mjml
Normal file
37
apps/builder/assets/emails/workspaceMemberInvitation.mjml
Normal 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>
|
536
apps/builder/assets/emails/workspaceMemberInvitation.ts
Normal file
536
apps/builder/assets/emails/workspaceMemberInvitation.ts
Normal 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>
|
||||
`
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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'
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { UsageContent } from './UsageContent'
|
@ -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,
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { BillingContent } from './BillingContent'
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const cancelSubscriptionQuery = (stripeId: string) =>
|
||||
sendRequest({
|
||||
url: `api/stripe/subscription?stripeId=${stripeId}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const redirectToBillingPortal = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
|
@ -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,
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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) => (
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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()
|
||||
|
@ -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."
|
||||
/>
|
||||
|
108
apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
Normal file
108
apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
339
apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
Normal file
339
apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
86
apps/builder/components/shared/ChangePlanForm/helpers.ts
Normal file
86
apps/builder/components/shared/ChangePlanForm/helpers.ts
Normal 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)
|
||||
}
|
1
apps/builder/components/shared/ChangePlanForm/index.ts
Normal file
1
apps/builder/components/shared/ChangePlanForm/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ChangePlanForm } from './ChangePlanForm'
|
@ -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
|
@ -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,
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
30
apps/builder/components/shared/PlanTag.tsx
Normal file
30
apps/builder/components/shared/PlanTag.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export const UploadButton = ({
|
||||
},
|
||||
],
|
||||
})
|
||||
if (urls.length) onFileUploaded(urls[0])
|
||||
if (urls.length && urls[0]) onFileUploaded(urls[0])
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
|
@ -7,12 +7,14 @@ export const useToast = () => {
|
||||
title,
|
||||
description,
|
||||
status = 'error',
|
||||
...props
|
||||
}: UseToastOptions) => {
|
||||
toast({
|
||||
position: 'bottom-right',
|
||||
description,
|
||||
title,
|
||||
status,
|
||||
...props,
|
||||
})
|
||||
}
|
||||
|
||||
|
53
apps/builder/components/shared/modals/ChangePlanModal.tsx
Normal file
53
apps/builder/components/shared/modals/ChangePlanModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -1 +0,0 @@
|
||||
export { UpgradeModal } from './UpgradeModal'
|
1
apps/builder/components/shared/modals/index.ts
Normal file
1
apps/builder/components/shared/modals/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ChangePlanModal } from './ChangePlanModal'
|
@ -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,
|
||||
|
@ -98,6 +98,12 @@ const components = {
|
||||
},
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
defaultProps: {
|
||||
rounded: 'md',
|
||||
hasArrow: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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 } },
|
||||
},
|
||||
})
|
@ -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)
|
49
apps/builder/pages/api/stripe/invoices.ts
Normal file
49
apps/builder/pages/api/stripe/invoices.ts
Normal 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)
|
240
apps/builder/pages/api/stripe/subscription.ts
Normal file
240
apps/builder/pages/api/stripe/subscription.ts
Normal 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)
|
@ -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)
|
@ -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' })
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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({
|
||||
|
@ -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)
|
||||
|
54
apps/builder/pages/api/workspaces/[workspaceId]/usage.ts
Normal file
54
apps/builder/pages/api/workspaces/[workspaceId]/usage.ts
Normal 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)
|
@ -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])
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
},
|
||||
|
15
apps/builder/playwright/firstUser.json
Normal file
15
apps/builder/playwright/firstUser.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "http://localhost:3000",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "typebot-20-modal",
|
||||
"value": "hide"
|
||||
},
|
||||
{ "name": "workspaceId", "value": "proWorkspace" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
15
apps/builder/playwright/secondUser.json
Normal file
15
apps/builder/playwright/secondUser.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "http://localhost:3000",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "typebot-20-modal",
|
||||
"value": "hide"
|
||||
},
|
||||
{ "name": "workspaceId", "value": "freeWorkspace" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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()
|
||||
|
@ -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,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
@ -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')
|
||||
|
175
apps/builder/playwright/tests/billing.spec.ts
Normal file
175
apps/builder/playwright/tests/billing.spec.ts
Normal 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()
|
||||
})
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
@ -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')
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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([
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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([
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 (
|
||||
|
@ -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 }) => {
|
||||
|
@ -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(
|
||||
|
@ -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
Reference in New Issue
Block a user