✨ 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>
|
||||||
<mj-section padding-top="20px">
|
<mj-section padding-top="20px">
|
||||||
<mj-column>
|
<mj-column>
|
||||||
<mj-text>You have been invited to collaborate on a typebot created by ${email}</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 "Shared with me "button 👍</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-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
<mj-section>
|
<mj-section>
|
||||||
<mj-column>
|
<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-column>
|
||||||
</mj-section>
|
</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>
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
</Icon>
|
</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 { StatsCards } from 'components/analytics/StatsCards'
|
||||||
import { Graph } from 'components/shared/Graph'
|
import { Graph } from 'components/shared/Graph'
|
||||||
import { useToast } from 'components/shared/hooks/useToast'
|
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 { GraphProvider, GroupsCoordinatesProvider } from 'contexts/GraphContext'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
import { Stats } from 'models'
|
import { Stats } from 'models'
|
||||||
@@ -49,7 +49,7 @@ export const AnalyticsContent = ({ stats }: { stats?: Stats }) => {
|
|||||||
<Spinner color="gray" />
|
<Spinner color="gray" />
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<UpgradeModal onClose={onClose} isOpen={isOpen} />
|
<ChangePlanModal onClose={onClose} isOpen={isOpen} />
|
||||||
<StatsCards stats={stats} pos="absolute" top={10} />
|
<StatsCards stats={stats} pos="absolute" top={10} />
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DashboardFolder, WorkspaceRole } from 'db'
|
import { DashboardFolder, WorkspaceRole } from 'db'
|
||||||
|
import { env } from 'utils'
|
||||||
import {
|
import {
|
||||||
Flex,
|
Flex,
|
||||||
Heading,
|
Heading,
|
||||||
@@ -160,9 +161,13 @@ export const FolderContent = ({ folder }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex w="full" flex="1" justify="center">
|
<Flex w="full" flex="1" justify="center">
|
||||||
{typebots && !isTypebotLoading && user && folder === null && (
|
{typebots &&
|
||||||
<OnboardingModal totalTypebots={typebots.length} />
|
!isTypebotLoading &&
|
||||||
)}
|
user &&
|
||||||
|
folder === null &&
|
||||||
|
env('E2E_TEST') !== 'true' && (
|
||||||
|
<OnboardingModal totalTypebots={typebots.length} />
|
||||||
|
)}
|
||||||
<Stack w="1000px" spacing={6}>
|
<Stack w="1000px" spacing={6}>
|
||||||
<Skeleton isLoaded={folder?.name !== undefined}>
|
<Skeleton isLoaded={folder?.name !== undefined}>
|
||||||
<Heading as="h1">{folder?.name}</Heading>
|
<Heading as="h1">{folder?.name}</Heading>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
|
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
|
||||||
import { FolderPlusIcon } from 'assets/icons'
|
import { FolderPlusIcon } from 'assets/icons'
|
||||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
import {
|
||||||
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
|
LimitReached,
|
||||||
|
ChangePlanModal,
|
||||||
|
} from 'components/shared/modals/ChangePlanModal'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { isFreePlan } from 'services/workspace'
|
import { isFreePlan } from 'services/workspace'
|
||||||
@@ -26,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
|||||||
<Text>Create a folder</Text>
|
<Text>Create a folder</Text>
|
||||||
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
|
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
|
||||||
</HStack>
|
</HStack>
|
||||||
<UpgradeModal
|
<ChangePlanModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
type={LimitReached.FOLDER}
|
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 { UnlockPlanInfo } from 'components/shared/Info'
|
||||||
import { useUser } from 'contexts/UserContext'
|
import { useUser } from 'contexts/UserContext'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
|
import { WorkspaceInvitation, WorkspaceRole } from 'db'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
deleteInvitation,
|
deleteInvitation,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useMembers,
|
useMembers,
|
||||||
} from 'services/workspace'
|
} from 'services/workspace'
|
||||||
import { AddMemberForm } from './AddMemberForm'
|
import { AddMemberForm } from './AddMemberForm'
|
||||||
|
import { checkCanInviteMember } from './helpers'
|
||||||
import { MemberItem } from './MemberItem'
|
import { MemberItem } from './MemberItem'
|
||||||
|
|
||||||
export const MembersList = () => {
|
export const MembersList = () => {
|
||||||
@@ -78,14 +79,19 @@ export const MembersList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canInviteNewMember = checkCanInviteMember({
|
||||||
|
plan: workspace?.plan,
|
||||||
|
currentMembersCount: [...(members ?? []), ...(invitations ?? [])].length,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack w="full">
|
<Stack w="full" gap="3">
|
||||||
{workspace?.plan !== Plan.TEAM && (
|
{!canInviteNewMember && (
|
||||||
<UnlockPlanInfo
|
<UnlockPlanInfo
|
||||||
contentLabel={
|
contentLabel={`
|
||||||
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
|
Upgrade your plan to work with more team members, and unlock awesome
|
||||||
}
|
power features 🚀
|
||||||
plan={Plan.TEAM}
|
`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{workspace?.id && canEdit && (
|
{workspace?.id && canEdit && (
|
||||||
@@ -94,7 +100,7 @@ export const MembersList = () => {
|
|||||||
onNewInvitation={handleNewInvitation}
|
onNewInvitation={handleNewInvitation}
|
||||||
onNewMember={handleNewMember}
|
onNewMember={handleNewMember}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isLocked={workspace.plan !== Plan.TEAM}
|
isLocked={!canInviteNewMember}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{members?.map((member) => (
|
{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 { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import { User, Workspace } from 'db'
|
import { User, Workspace } from 'db'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { BillingForm } from './BillingForm'
|
import { BillingContent } from './BillingContent'
|
||||||
import { MembersList } from './MembersList'
|
import { MembersList } from './MembersList'
|
||||||
import { MyAccountForm } from './MyAccountForm'
|
import { MyAccountForm } from './MyAccountForm'
|
||||||
import { EditorSettingsForm } from './EditorSettingsForm'
|
import { EditorSettingsForm } from './EditorSettingsForm'
|
||||||
@@ -50,13 +50,12 @@ export const WorkspaceSettingsModal = ({
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent h="600px" flexDir="row">
|
<ModalContent minH="600px" flexDir="row">
|
||||||
<Stack
|
<Stack
|
||||||
spacing={8}
|
spacing={8}
|
||||||
w="250px"
|
w="200px"
|
||||||
py="6"
|
py="6"
|
||||||
borderRightWidth={1}
|
borderRightWidth={1}
|
||||||
h="full"
|
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
>
|
>
|
||||||
<Stack spacing={8}>
|
<Stack spacing={8}>
|
||||||
@@ -134,7 +133,7 @@ export const WorkspaceSettingsModal = ({
|
|||||||
justifyContent="flex-start"
|
justifyContent="flex-start"
|
||||||
pl="4"
|
pl="4"
|
||||||
>
|
>
|
||||||
Billing
|
Billing & Usage
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -174,7 +173,7 @@ const SettingsContent = ({
|
|||||||
case 'members':
|
case 'members':
|
||||||
return <MembersList />
|
return <MembersList />
|
||||||
case 'billing':
|
case 'billing':
|
||||||
return <BillingForm />
|
return <BillingContent />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Stack } from '@chakra-ui/react'
|
import { Stack } from '@chakra-ui/react'
|
||||||
import { SubmissionsTable } from 'components/results/ResultsTable'
|
import { SubmissionsTable } from 'components/results/ResultsTable'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
|
||||||
import { LogsModal } from './LogsModal'
|
import { LogsModal } from './LogsModal'
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { Plan } from 'db'
|
|
||||||
import { useResults } from 'contexts/ResultsProvider'
|
import { useResults } from 'contexts/ResultsProvider'
|
||||||
import { ResultModal } from './ResultModal'
|
import { ResultModal } from './ResultModal'
|
||||||
|
|
||||||
@@ -14,7 +12,6 @@ export const ResultsContent = () => {
|
|||||||
fetchMore,
|
fetchMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
resultHeader,
|
resultHeader,
|
||||||
totalHiddenResults,
|
|
||||||
tableData,
|
tableData,
|
||||||
} = useResults()
|
} = useResults()
|
||||||
const { typebot, publishedTypebot } = useTypebot()
|
const { typebot, publishedTypebot } = useTypebot()
|
||||||
@@ -46,13 +43,6 @@ export const ResultsContent = () => {
|
|||||||
overflow="scroll"
|
overflow="scroll"
|
||||||
w="full"
|
w="full"
|
||||||
>
|
>
|
||||||
{totalHiddenResults && (
|
|
||||||
<UnlockPlanInfo
|
|
||||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
|
||||||
contentLabel="You are seeing complete submissions only."
|
|
||||||
plan={Plan.PRO}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{publishedTypebot && (
|
{publishedTypebot && (
|
||||||
<LogsModal
|
<LogsModal
|
||||||
typebotId={publishedTypebot?.typebotId}
|
typebotId={publishedTypebot?.typebotId}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export const ResultsActionButtons = ({
|
|||||||
resultHeader,
|
resultHeader,
|
||||||
mutate,
|
mutate,
|
||||||
totalResults,
|
totalResults,
|
||||||
totalHiddenResults,
|
|
||||||
tableData,
|
tableData,
|
||||||
onDeleteResults,
|
onDeleteResults,
|
||||||
} = useResults()
|
} = useResults()
|
||||||
@@ -57,7 +56,7 @@ export const ResultsActionButtons = ({
|
|||||||
|
|
||||||
const totalSelected =
|
const totalSelected =
|
||||||
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
||||||
? totalResults - (totalHiddenResults ?? 0)
|
? totalResults
|
||||||
: selectedResultsId.length
|
: selectedResultsId.length
|
||||||
|
|
||||||
const deleteResults = async () => {
|
const deleteResults = async () => {
|
||||||
@@ -87,9 +86,7 @@ export const ResultsActionButtons = ({
|
|||||||
|
|
||||||
const exportResultsToCSV = async () => {
|
const exportResultsToCSV = async () => {
|
||||||
setIsExportLoading(true)
|
setIsExportLoading(true)
|
||||||
const isSelectAll =
|
const isSelectAll = totalSelected === 0 || totalSelected === totalResults
|
||||||
totalSelected === 0 ||
|
|
||||||
totalSelected === totalResults - (totalHiddenResults ?? 0)
|
|
||||||
|
|
||||||
const dataToUnparse = isSelectAll
|
const dataToUnparse = isSelectAll
|
||||||
? await getAllTableData()
|
? await getAllTableData()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react'
|
} 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 { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import { GeneralSettings } from 'models'
|
import { GeneralSettings } from 'models'
|
||||||
@@ -56,7 +56,7 @@ export const GeneralSettingsForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
<UpgradeModal isOpen={isOpen} onClose={onClose} />
|
<ChangePlanModal isOpen={isOpen} onClose={onClose} />
|
||||||
<Flex
|
<Flex
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
@@ -82,7 +82,11 @@ export const GeneralSettingsForm = ({
|
|||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
id="new-result"
|
id="new-result"
|
||||||
label="Remember session"
|
label="Remember session"
|
||||||
initialValue={isDefined(generalSettings.isNewResultOnRefreshEnabled) ? !generalSettings.isNewResultOnRefreshEnabled : true}
|
initialValue={
|
||||||
|
isDefined(generalSettings.isNewResultOnRefreshEnabled)
|
||||||
|
? !generalSettings.isNewResultOnRefreshEnabled
|
||||||
|
: true
|
||||||
|
}
|
||||||
onCheckChange={handleNewResultOnRefreshChange}
|
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."
|
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 { loadStripe } from '@stripe/stripe-js/pure'
|
||||||
|
import { Plan, User } from 'db'
|
||||||
import { env, isDefined, isEmpty, sendRequest } from 'utils'
|
import { env, isDefined, isEmpty, sendRequest } from 'utils'
|
||||||
|
import { guessIfUserIsEuropean } from '../helpers'
|
||||||
|
|
||||||
type Props = {
|
type UpgradeProps = {
|
||||||
user: User
|
user: User
|
||||||
customerId?: string
|
stripeId?: string
|
||||||
currency: 'usd' | 'eur'
|
plan: Plan
|
||||||
plan: 'pro' | 'team'
|
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
|
additionalChats: number
|
||||||
|
additionalStorage: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pay = async ({
|
export const pay = async ({
|
||||||
customerId,
|
stripeId,
|
||||||
...props
|
...props
|
||||||
}: Props): Promise<{ newPlan: Plan } | undefined | void> =>
|
}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> =>
|
||||||
isDefined(customerId)
|
isDefined(stripeId)
|
||||||
? updatePlan({ ...props, customerId })
|
? updatePlan({ ...props, stripeId })
|
||||||
: redirectToCheckout(props)
|
: redirectToCheckout(props)
|
||||||
|
|
||||||
const updatePlan = async ({
|
export const updatePlan = async ({
|
||||||
customerId,
|
stripeId,
|
||||||
plan,
|
plan,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
currency,
|
additionalChats,
|
||||||
}: Omit<Props, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
|
additionalStorage,
|
||||||
|
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
|
||||||
const { data, error } = await sendRequest<{ message: string }>({
|
const { data, error } = await sendRequest<{ message: string }>({
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
url: '/api/stripe/update-subscription',
|
url: '/api/stripe/subscription',
|
||||||
body: { workspaceId, plan, customerId, currency },
|
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
|
||||||
})
|
})
|
||||||
if (error || !data) return
|
if (error || !data) return
|
||||||
return { newPlan: plan === 'team' ? Plan.TEAM : Plan.PRO }
|
return { newPlan: plan }
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectToCheckout = async ({
|
export const redirectToCheckout = async ({
|
||||||
user,
|
user,
|
||||||
currency,
|
|
||||||
plan,
|
plan,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}: Omit<Props, 'customerId'>) => {
|
additionalChats,
|
||||||
|
additionalStorage,
|
||||||
|
}: Omit<UpgradeProps, 'customerId'>) => {
|
||||||
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
|
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
|
||||||
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
|
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
|
||||||
const { data, error } = await sendRequest<{ sessionId: string }>({
|
const { data, error } = await sendRequest<{ sessionId: string }>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/stripe/checkout',
|
url: '/api/stripe/subscription',
|
||||||
body: {
|
body: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
currency,
|
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
|
||||||
plan,
|
plan,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
href: location.origin + location.pathname,
|
href: location.origin + location.pathname,
|
||||||
|
additionalChats,
|
||||||
|
additionalStorage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (error || !data) return
|
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,
|
Text,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { Plan } from 'db'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UpgradeModal } from './modals/UpgradeModal'
|
import { ChangePlanModal } from './modals/ChangePlanModal'
|
||||||
import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
|
import { LimitReached } from './modals/ChangePlanModal'
|
||||||
|
|
||||||
export const Info = (props: AlertProps) => (
|
export const Info = (props: AlertProps) => (
|
||||||
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
|
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
|
||||||
@@ -27,30 +26,34 @@ export const UnlockPlanInfo = ({
|
|||||||
contentLabel,
|
contentLabel,
|
||||||
buttonLabel = 'More info',
|
buttonLabel = 'More info',
|
||||||
type,
|
type,
|
||||||
plan = Plan.PRO,
|
...props
|
||||||
}: {
|
}: {
|
||||||
contentLabel: string
|
contentLabel: React.ReactNode
|
||||||
buttonLabel?: string
|
buttonLabel?: string
|
||||||
type?: LimitReached
|
type?: LimitReached
|
||||||
plan: Plan
|
} & AlertProps) => {
|
||||||
}) => {
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
status="info"
|
status="info"
|
||||||
bgColor={'blue.50'}
|
|
||||||
rounded="md"
|
rounded="md"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<HStack>
|
<HStack>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<Text>{contentLabel}</Text>
|
<Text>{contentLabel}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
|
<Button
|
||||||
|
colorScheme={props.status === 'warning' ? 'orange' : 'blue'}
|
||||||
|
onClick={onOpen}
|
||||||
|
flexShrink={0}
|
||||||
|
ml="2"
|
||||||
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
|
|
||||||
export const MoreInfoTooltip = ({ children }: Props) => {
|
export const MoreInfoTooltip = ({ children }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip label={children}>
|
<Tooltip label={children} hasArrow rounded="md" p="3">
|
||||||
<chakra.span cursor="pointer">
|
<chakra.span cursor="pointer">
|
||||||
<HelpCircleIcon />
|
<HelpCircleIcon />
|
||||||
</chakra.span>
|
</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 { ChevronLeftIcon } from 'assets/icons'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import { Plan } from 'db'
|
|
||||||
import { InputBlockType } from 'models'
|
import { InputBlockType } from 'models'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { timeSince } from 'services/utils'
|
import { timeSince } from 'services/utils'
|
||||||
import { isFreePlan } from 'services/workspace'
|
import { isFreePlan } from 'services/workspace'
|
||||||
import { isNotDefined } from 'utils'
|
import { isNotDefined } from 'utils'
|
||||||
import { UpgradeModal } from '../modals/UpgradeModal'
|
import { LimitReached, ChangePlanModal } from '../modals/ChangePlanModal'
|
||||||
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
|
|
||||||
|
|
||||||
export const PublishButton = (props: ButtonProps) => {
|
export const PublishButton = (props: ButtonProps) => {
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
@@ -50,8 +48,7 @@ export const PublishButton = (props: ButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack spacing="1px">
|
<HStack spacing="1px">
|
||||||
<UpgradeModal
|
<ChangePlanModal
|
||||||
plan={Plan.PRO}
|
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
type={LimitReached.FILE_INPUT}
|
type={LimitReached.FILE_INPUT}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
|
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
import { Plan } from 'db'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { isNotDefined } from 'utils'
|
import { isNotDefined } from 'utils'
|
||||||
import { UpgradeModal } from '../modals/UpgradeModal'
|
import { ChangePlanModal } from '../modals/ChangePlanModal'
|
||||||
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
|
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 { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +18,7 @@ export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
|
|||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
>
|
>
|
||||||
{props.children ?? 'Upgrade'}
|
{props.children ?? 'Upgrade'}
|
||||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const UploadButton = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (urls.length) onFileUploaded(urls[0])
|
if (urls.length && urls[0]) onFileUploaded(urls[0])
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ export const useToast = () => {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status = 'error',
|
status = 'error',
|
||||||
|
...props
|
||||||
}: UseToastOptions) => {
|
}: UseToastOptions) => {
|
||||||
toast({
|
toast({
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
description,
|
description,
|
||||||
title,
|
title,
|
||||||
status,
|
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
|
hasMore: boolean
|
||||||
resultHeader: ResultHeaderCell[]
|
resultHeader: ResultHeaderCell[]
|
||||||
totalResults: number
|
totalResults: number
|
||||||
totalHiddenResults?: number
|
|
||||||
tableData: TableData[]
|
tableData: TableData[]
|
||||||
onDeleteResults: (totalResultsDeleted: number) => void
|
onDeleteResults: (totalResultsDeleted: number) => void
|
||||||
fetchMore: () => void
|
fetchMore: () => void
|
||||||
@@ -33,14 +32,12 @@ export const ResultsProvider = ({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
typebotId,
|
typebotId,
|
||||||
totalResults,
|
totalResults,
|
||||||
totalHiddenResults,
|
|
||||||
onDeleteResults,
|
onDeleteResults,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
typebotId: string
|
typebotId: string
|
||||||
totalResults: number
|
totalResults: number
|
||||||
totalHiddenResults?: number
|
|
||||||
onDeleteResults: (totalResultsDeleted: number) => void
|
onDeleteResults: (totalResultsDeleted: number) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { publishedTypebot, linkedTypebots } = useTypebot()
|
const { publishedTypebot, linkedTypebots } = useTypebot()
|
||||||
@@ -84,7 +81,6 @@ export const ResultsProvider = ({
|
|||||||
tableData,
|
tableData,
|
||||||
resultHeader,
|
resultHeader,
|
||||||
totalResults,
|
totalResults,
|
||||||
totalHiddenResults,
|
|
||||||
onDeleteResults,
|
onDeleteResults,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
mutate,
|
mutate,
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ const components = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Tooltip: {
|
||||||
|
defaultProps: {
|
||||||
|
rounded: 'md',
|
||||||
|
hasArrow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "pnpm playwright test",
|
"test": "pnpm playwright test",
|
||||||
"test:open": "PWDEBUG=1 pnpm playwright test"
|
"test:open": "NO_RETRIES=1 pnpm playwright test --debug"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/css-reset": "2.0.7",
|
"@chakra-ui/css-reset": "2.0.7",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @next/next/no-sync-scripts */
|
||||||
import Document, {
|
import Document, {
|
||||||
Html,
|
Html,
|
||||||
Head,
|
Head,
|
||||||
@@ -22,7 +23,6 @@ class MyDocument extends Document {
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
|
||||||
<script src="/__env.js" />
|
<script src="/__env.js" />
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { withSentry } from '@sentry/nextjs'
|
|||||||
import { CustomAdapter } from './adapter'
|
import { CustomAdapter } from './adapter'
|
||||||
import { User } from 'db'
|
import { User } from 'db'
|
||||||
import { env, isNotEmpty } from 'utils'
|
import { env, isNotEmpty } from 'utils'
|
||||||
|
import { mockedUser } from 'services/api/utils'
|
||||||
|
|
||||||
const providers: Provider[] = []
|
const providers: Provider[] = []
|
||||||
|
|
||||||
@@ -98,6 +99,14 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
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') {
|
if (req.method === 'HEAD') {
|
||||||
res.status(200)
|
res.status(200)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
|||||||
name: data.name
|
name: data.name
|
||||||
? `${data.name}'s workspace`
|
? `${data.name}'s workspace`
|
||||||
: `My workspace`,
|
: `My workspace`,
|
||||||
plan:
|
...(process.env.ADMIN_EMAIL === data.email
|
||||||
process.env.ADMIN_EMAIL === data.email
|
? { plan: Plan.LIFETIME }
|
||||||
? Plan.TEAM
|
: {
|
||||||
: Plan.FREE,
|
plan: Plan.FREE,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const workspaceId = req.query.workspaceId as string | undefined
|
const stripeId = req.query.stripeId as string | undefined
|
||||||
if (!workspaceId) return badRequest(res)
|
if (!stripeId) return badRequest(res)
|
||||||
if (!process.env.STRIPE_SECRET_KEY)
|
if (!process.env.STRIPE_SECRET_KEY)
|
||||||
throw Error('STRIPE_SECRET_KEY var is missing')
|
throw Error('STRIPE_SECRET_KEY var is missing')
|
||||||
const workspace = await prisma.workspace.findFirst({
|
const workspace = await prisma.workspace.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: workspaceId,
|
stripeId,
|
||||||
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
|
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 Cors from 'micro-cors'
|
||||||
import { buffer } from 'micro'
|
import { buffer } from 'micro'
|
||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
import { Plan } from 'db'
|
|
||||||
import { withSentry } from '@sentry/nextjs'
|
import { withSentry } from '@sentry/nextjs'
|
||||||
|
|
||||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
|
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) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
const session = event.data.object as Stripe.Checkout.Session
|
const session = event.data.object as Stripe.Checkout.Session
|
||||||
const { metadata } = session
|
const { workspaceId, plan, additionalChats, additionalStorage } =
|
||||||
if (!metadata?.workspaceId || !metadata?.plan)
|
session.metadata as unknown as {
|
||||||
return res.status(500).send({ message: `customer_email not found` })
|
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({
|
await prisma.workspace.update({
|
||||||
where: { id: metadata.workspaceId },
|
where: { id: workspaceId },
|
||||||
data: {
|
data: {
|
||||||
plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO,
|
plan: plan,
|
||||||
stripeId: session.customer as string,
|
stripeId: session.customer as string,
|
||||||
|
additionalChatsIndex: parseInt(additionalChats),
|
||||||
|
additionalStorageIndex: parseInt(additionalStorage),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return res.status(200).send({ message: 'workspace upgraded in DB' })
|
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: {
|
default: {
|
||||||
return res.status(304).send({ message: 'event not handled' })
|
return res.status(304).send({ message: 'event not handled' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CollaborationType, WorkspaceRole } from 'db'
|
|||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
|
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
|
||||||
import { sendEmailNotification } from 'services/api/emails'
|
import { sendEmailNotification } from 'utils'
|
||||||
import { getAuthenticatedUser } from 'services/api/utils'
|
import { getAuthenticatedUser } from 'services/api/utils'
|
||||||
import {
|
import {
|
||||||
badRequest,
|
badRequest,
|
||||||
@@ -29,6 +29,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const typebot = await prisma.typebot.findFirst({
|
const typebot = await prisma.typebot.findFirst({
|
||||||
where: canWriteTypebot(typebotId, user),
|
where: canWriteTypebot(typebotId, user),
|
||||||
|
include: { workspace: { select: { name: true } } },
|
||||||
})
|
})
|
||||||
if (!typebot || !typebot.workspaceId) return forbidden(res)
|
if (!typebot || !typebot.workspaceId) return forbidden(res)
|
||||||
const { email, type } =
|
const { email, type } =
|
||||||
@@ -70,10 +71,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
await sendEmailNotification({
|
await sendEmailNotification({
|
||||||
to: email,
|
to: email,
|
||||||
subject: "You've been invited to collaborate 🤝",
|
subject: "You've been invited to collaborate 🤝",
|
||||||
content: invitationToCollaborate(
|
html: invitationToCollaborate({
|
||||||
user.email ?? '',
|
hostEmail: user.email ?? '',
|
||||||
`${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`
|
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`,
|
||||||
),
|
guestEmail: email.toLowerCase(),
|
||||||
|
typebotName: typebot.name,
|
||||||
|
workspaceName: typebot.workspace?.name ?? '',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
return res.send({
|
return res.send({
|
||||||
message: 'success',
|
message: 'success',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { withSentry } from '@sentry/nextjs'
|
import { withSentry } from '@sentry/nextjs'
|
||||||
import { Workspace } from 'db'
|
import { Plan, Workspace } from 'db'
|
||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { getAuthenticatedUser } from 'services/api/utils'
|
import { getAuthenticatedUser } from 'services/api/utils'
|
||||||
@@ -22,7 +22,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
members: { create: [{ role: 'ADMIN', userId: user.id }] },
|
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({
|
return res.status(200).json({
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { withSentry } from '@sentry/nextjs'
|
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 prisma from 'libs/prisma'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { sendEmailNotification } from 'utils'
|
||||||
import { getAuthenticatedUser } from 'services/api/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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
@@ -20,6 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!workspace) return forbidden(res)
|
if (!workspace) return forbidden(res)
|
||||||
|
|
||||||
|
if (await checkIfSeatsLimitReached(workspace))
|
||||||
|
return res.status(400).send('Seats limit reached')
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
await prisma.memberInWorkspace.create({
|
await prisma.memberInWorkspace.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -37,11 +48,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
workspaceId: data.workspaceId,
|
workspaceId: data.workspaceId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
} else await prisma.workspaceInvitation.create({ data })
|
||||||
const invitation = await prisma.workspaceInvitation.create({ data })
|
if (env('E2E_TEST') !== 'true')
|
||||||
return res.send({ invitation })
|
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)
|
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)
|
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 { TypebotDndContext } from 'contexts/TypebotDndContext'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Spinner, Stack, Text, VStack } from '@chakra-ui/react'
|
import { Spinner, Stack, Text, VStack } from '@chakra-ui/react'
|
||||||
import { pay } from 'services/stripe'
|
|
||||||
import { useUser } from 'contexts/UserContext'
|
import { useUser } from 'contexts/UserContext'
|
||||||
import { NextPageContext } from 'next/types'
|
import { NextPageContext } from 'next/types'
|
||||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||||
|
import { Plan } from 'db'
|
||||||
|
import { pay } from 'components/shared/ChangePlanForm/queries/updatePlan'
|
||||||
|
|
||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -17,16 +18,15 @@ const DashboardPage = () => {
|
|||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
|
const subscribePlan = query.subscribePlan as 'pro' | 'starter' | undefined
|
||||||
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
pay({
|
pay({
|
||||||
user,
|
user,
|
||||||
plan: subscribePlan,
|
plan: subscribePlan === 'pro' ? Plan.PRO : Plan.STARTER,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
currency: navigator.languages.find((l) => l.includes('fr'))
|
additionalChats: 0,
|
||||||
? 'eur'
|
additionalStorage: 0,
|
||||||
: 'usd',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [query, user, workspace])
|
}, [query, user, workspace])
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { useRouter } from 'next/router'
|
|||||||
import { useStats } from 'services/analytics'
|
import { useStats } from 'services/analytics'
|
||||||
import { useToast } from 'components/shared/hooks/useToast'
|
import { useToast } from 'components/shared/hooks/useToast'
|
||||||
import { ResultsProvider } from 'contexts/ResultsProvider'
|
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 ResultsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -26,6 +32,45 @@ const ResultsPage = () => {
|
|||||||
typebotId: publishedTypebot?.typebotId,
|
typebotId: publishedTypebot?.typebotId,
|
||||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
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) => {
|
const handleDeletedResults = (total: number) => {
|
||||||
if (!stats) return
|
if (!stats) return
|
||||||
@@ -40,6 +85,38 @@ const ResultsPage = () => {
|
|||||||
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
|
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
|
||||||
/>
|
/>
|
||||||
<TypebotHeader />
|
<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 h="full" w="full">
|
||||||
<Flex
|
<Flex
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ require('dotenv').config({
|
|||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
|
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
|
||||||
testDir: path.join(__dirname, 'playwright/tests'),
|
testDir: path.join(__dirname, 'playwright/tests'),
|
||||||
timeout: 10 * 2000,
|
|
||||||
expect: {
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
retries: process.env.NO_RETRIES ? 0 : 2,
|
retries: process.env.NO_RETRIES ? 0 : 2,
|
||||||
workers: process.env.CI ? 1 : 3,
|
workers: process.env.CI ? 1 : 3,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
@@ -21,7 +17,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
actionTimeout: 0,
|
actionTimeout: 0,
|
||||||
baseURL: process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL,
|
baseURL: process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
storageState: path.join(__dirname, 'playwright/proUser.json'),
|
storageState: path.join(__dirname, 'playwright/firstUser.json'),
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
locale: 'en-US',
|
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)
|
document.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockSessionApiCalls = (page: Page) =>
|
export const connectedAsOtherUser = async (page: Page) =>
|
||||||
page.route('/api/auth/session', (route) => {
|
page.route('/api/auth/session', (route) => {
|
||||||
if (route.request().method() === 'GET') {
|
if (route.request().method() === 'GET') {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
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()
|
return route.continue()
|
||||||
|
|||||||
@@ -15,52 +15,129 @@ import {
|
|||||||
PrismaClient,
|
PrismaClient,
|
||||||
User,
|
User,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
|
Workspace,
|
||||||
} from 'db'
|
} from 'db'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { encrypt } from 'utils'
|
import { encrypt, createFakeResults } from 'utils'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
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 freeWorkspaceId = 'freeWorkspace'
|
||||||
export const sharedWorkspaceId = 'sharedWorkspace'
|
export const starterWorkspaceId = 'starterWorkspace'
|
||||||
export const guestWorkspaceId = 'guestWorkspace'
|
export const proWorkspaceId = 'proWorkspace'
|
||||||
|
const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
|
||||||
|
|
||||||
export const teardownDatabase = async () => {
|
export const teardownDatabase = async () => {
|
||||||
const ownerFilter = {
|
|
||||||
where: {
|
|
||||||
workspace: {
|
|
||||||
members: { some: { userId: { in: ['freeUser', 'proUser'] } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await prisma.workspace.deleteMany({
|
await prisma.workspace.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
members: {
|
members: {
|
||||||
some: { userId: { in: ['freeUser', 'proUser'] } },
|
some: { userId: { in: [userId, otherUserId] } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await prisma.user.deleteMany({
|
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 () => {
|
export const setupDatabase = async () => {
|
||||||
|
await createWorkspaces()
|
||||||
await createUsers()
|
await createUsers()
|
||||||
return createCredentials()
|
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 () => {
|
export const createUsers = async () => {
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
id: 'proUser',
|
id: userId,
|
||||||
email: 'pro-user@email.com',
|
email: 'user@email.com',
|
||||||
name: 'Pro user',
|
name: 'John Doe',
|
||||||
graphNavigation: GraphNavigation.TRACKPAD,
|
graphNavigation: GraphNavigation.TRACKPAD,
|
||||||
apiTokens: {
|
apiTokens: {
|
||||||
createMany: {
|
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({
|
await prisma.user.create({
|
||||||
data: {
|
data: { id: otherUserId, email: 'other-user@email.com', name: 'James Doe' },
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await prisma.workspace.create({
|
return prisma.memberInWorkspace.createMany({
|
||||||
data: {
|
data: [
|
||||||
id: freeWorkspaceId,
|
{
|
||||||
name: 'Free Shared workspace',
|
role: WorkspaceRole.ADMIN,
|
||||||
plan: Plan.FREE,
|
userId,
|
||||||
members: {
|
workspaceId: freeWorkspaceId,
|
||||||
createMany: {
|
|
||||||
data: [
|
|
||||||
{ role: WorkspaceRole.MEMBER, userId: 'proUser' },
|
|
||||||
{ role: WorkspaceRole.ADMIN, userId: 'freeUser' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
})
|
role: WorkspaceRole.ADMIN,
|
||||||
return prisma.workspace.create({
|
userId,
|
||||||
data: {
|
workspaceId: starterWorkspaceId,
|
||||||
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: proWorkspaceId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: WorkspaceRole.ADMIN,
|
||||||
|
userId,
|
||||||
|
workspaceId: lifetimeWorkspaceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +215,12 @@ export const getSignedInUser = (email: string) =>
|
|||||||
|
|
||||||
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
|
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
|
||||||
await prisma.typebot.createMany({
|
await prisma.typebot.createMany({
|
||||||
data: partialTypebots.map(parseTestTypebot) as any[],
|
data: partialTypebots.map(parseTestTypebot),
|
||||||
})
|
})
|
||||||
return prisma.publicTypebot.createMany({
|
return prisma.publicTypebot.createMany({
|
||||||
data: partialTypebots.map((t) =>
|
data: partialTypebots.map((t) =>
|
||||||
parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t))
|
parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t))
|
||||||
) as any[],
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,43 +259,11 @@ export const updateUser = (data: Partial<User>) =>
|
|||||||
prisma.user.update({
|
prisma.user.update({
|
||||||
data,
|
data,
|
||||||
where: {
|
where: {
|
||||||
id: 'proUser',
|
id: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createResults = async ({ typebotId }: { typebotId: string }) => {
|
export const createResults = createFakeResults(prisma)
|
||||||
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 createFolder = (workspaceId: string, name: string) =>
|
export const createFolder = (workspaceId: string, name: string) =>
|
||||||
prisma.dashboardFolder.create({
|
prisma.dashboardFolder.create({
|
||||||
@@ -352,6 +362,6 @@ export const importTypebotInDatabase = async (
|
|||||||
data: parseTypebotToPublicTypebot(
|
data: parseTypebotToPublicTypebot(
|
||||||
updates?.id ? `${updates?.id}-public` : 'publicBot',
|
updates?.id ? `${updates?.id}-public` : 'publicBot',
|
||||||
typebot
|
typebot
|
||||||
) as any,
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import test, { expect } from '@playwright/test'
|
import test, { expect } from '@playwright/test'
|
||||||
import path from 'path'
|
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 }) => {
|
test('should display user info properly', async ({ page }) => {
|
||||||
await page.goto('/typebots')
|
await page.goto('/typebots')
|
||||||
await page.click('text=Settings & Members')
|
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 { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
|
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
|
||||||
const siteSrc = 'https://app.cal.com/baptistearno/15min'
|
const siteSrc = 'https://app.cal.com/baptistearno/15min'
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Embed bubble block', () => {
|
test.describe.parallel('Embed bubble block', () => {
|
||||||
test.describe('Content settings', () => {
|
test.describe('Content settings', () => {
|
||||||
test('should import and parse embed correctly', async ({ page }) => {
|
test('should import and parse embed correctly', async ({ page }) => {
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ import { BubbleBlockType, defaultImageBubbleContent } from 'models'
|
|||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const unsplashImageSrc =
|
const unsplashImageSrc =
|
||||||
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
|
'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.parallel('Image bubble block', () => {
|
||||||
test.describe('Content settings', () => {
|
test.describe('Content settings', () => {
|
||||||
test('should upload image file correctly', async ({ page }) => {
|
test('should upload image file correctly', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
|
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Text bubble block', () => {
|
test.describe('Text bubble block', () => {
|
||||||
test('rich text features should work', async ({ page }) => {
|
test('rich text features should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ import {
|
|||||||
} from 'models'
|
} from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const videoSrc =
|
const videoSrc =
|
||||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
|
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
|
||||||
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
const vimeoVideoSrc = 'https://vimeo.com/649301125'
|
const vimeoVideoSrc = 'https://vimeo.com/649301125'
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Video bubble block', () => {
|
test.describe.parallel('Video bubble block', () => {
|
||||||
test.describe('Content settings', () => {
|
test.describe('Content settings', () => {
|
||||||
test('should import video url correctly', async ({ page }) => {
|
test('should import video url correctly', async ({ page }) => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import cuid from 'cuid'
|
|||||||
import { CollaborationType, Plan, WorkspaceRole } from 'db'
|
import { CollaborationType, Plan, WorkspaceRole } from 'db'
|
||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
import { InputBlockType, defaultTextInputOptions } from 'models'
|
import { InputBlockType, defaultTextInputOptions } from 'models'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
import {
|
import {
|
||||||
createFolder,
|
createFolder,
|
||||||
createResults,
|
createResults,
|
||||||
@@ -11,8 +10,6 @@ import {
|
|||||||
parseDefaultGroupWithBlock,
|
parseDefaultGroupWithBlock,
|
||||||
} from '../services/database'
|
} from '../services/database'
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Typebot owner', () => {
|
test.describe('Typebot owner', () => {
|
||||||
test('Can invite collaborators', async ({ page }) => {
|
test('Can invite collaborators', async ({ page }) => {
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
@@ -101,7 +98,7 @@ test.describe('Collaborator', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await createFolder(guestWorkspaceId, 'Guest folder')
|
await createFolder(guestWorkspaceId, 'Guest folder')
|
||||||
await createResults({ typebotId })
|
await createResults({ typebotId, count: 10 })
|
||||||
await page.goto(`/typebots`)
|
await page.goto(`/typebots`)
|
||||||
await page.click("text=Pro user's workspace")
|
await page.click("text=Pro user's workspace")
|
||||||
await page.click('text=Guest workspace #2')
|
await page.click('text=Guest workspace #2')
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import test, { expect } from '@playwright/test'
|
|||||||
import { InputBlockType, defaultTextInputOptions } from 'models'
|
import { InputBlockType, defaultTextInputOptions } from 'models'
|
||||||
import {
|
import {
|
||||||
createTypebots,
|
createTypebots,
|
||||||
freeWorkspaceId,
|
|
||||||
parseDefaultGroupWithBlock,
|
parseDefaultGroupWithBlock,
|
||||||
|
starterWorkspaceId,
|
||||||
} from '../services/database'
|
} from '../services/database'
|
||||||
import path from 'path'
|
|
||||||
import cuid from 'cuid'
|
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 }) => {
|
test('should be able to connect custom domain', async ({ page }) => {
|
||||||
const typebotId = cuid()
|
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()
|
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Free workspace', () => {
|
test.describe('Starter workspace', () => {
|
||||||
test.use({
|
|
||||||
storageState: path.join(__dirname, '../freeUser.json'),
|
|
||||||
})
|
|
||||||
test("Add my domain shouldn't be available", async ({ page }) => {
|
test("Add my domain shouldn't be available", async ({ page }) => {
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
await createTypebots([
|
await createTypebots([
|
||||||
{
|
{
|
||||||
id: typebotId,
|
id: typebotId,
|
||||||
workspaceId: freeWorkspaceId,
|
workspaceId: starterWorkspaceId,
|
||||||
...parseDefaultGroupWithBlock({
|
...parseDefaultGroupWithBlock({
|
||||||
type: InputBlockType.TEXT,
|
type: InputBlockType.TEXT,
|
||||||
options: defaultTextInputOptions,
|
options: defaultTextInputOptions,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import test, { expect, Page } from '@playwright/test'
|
import test, { expect, Page } from '@playwright/test'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
import { createFolders, createTypebots } from '../services/database'
|
import { createFolders, createTypebots } from '../services/database'
|
||||||
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Dashboard page', () => {
|
test.describe('Dashboard page', () => {
|
||||||
test('folders navigation should work', async ({ page }) => {
|
test('folders navigation should work', async ({ page }) => {
|
||||||
await page.goto('/typebots')
|
await page.goto('/typebots')
|
||||||
@@ -79,7 +76,7 @@ test.describe('Dashboard page', () => {
|
|||||||
|
|
||||||
test.describe('Free user', () => {
|
test.describe('Free user', () => {
|
||||||
test.use({
|
test.use({
|
||||||
storageState: path.join(__dirname, '../freeUser.json'),
|
storageState: path.join(__dirname, '../secondUser.json'),
|
||||||
})
|
})
|
||||||
test("create folder shouldn't be available", async ({ page }) => {
|
test("create folder shouldn't be available", async ({ page }) => {
|
||||||
await page.goto('/typebots')
|
await page.goto('/typebots')
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { defaultTextInputOptions, InputBlockType } from 'models'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { typebotViewer } from '../services/selectorUtils'
|
import { typebotViewer } from '../services/selectorUtils'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Editor', () => {
|
test.describe.parallel('Editor', () => {
|
||||||
test('Edges connection should work', async ({ page }) => {
|
test('Edges connection should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { defaultChoiceInputOptions, InputBlockType, ItemType } from 'models'
|
|||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Buttons input block', () => {
|
test.describe.parallel('Buttons input block', () => {
|
||||||
test('can edit button items', async ({ page }) => {
|
test('can edit button items', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultDateInputOptions, InputBlockType } from 'models'
|
import { defaultDateInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Date input block', () => {
|
test.describe('Date input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultEmailInputOptions, InputBlockType } from 'models'
|
import { defaultEmailInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Email input block', () => {
|
test.describe('Email input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { defaultFileInputOptions, InputBlockType } from 'models'
|
|||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' })
|
test.describe.configure({ mode: 'parallel' })
|
||||||
|
|
||||||
@@ -61,9 +58,6 @@ test('options should work', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Free workspace', () => {
|
test.describe('Free workspace', () => {
|
||||||
test.use({
|
|
||||||
storageState: path.join(__dirname, '../../freeUser.json'),
|
|
||||||
})
|
|
||||||
test("shouldn't be able to publish typebot", async ({ page }) => {
|
test("shouldn't be able to publish typebot", async ({ page }) => {
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
await createTypebots([
|
await createTypebots([
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultNumberInputOptions, InputBlockType } from 'models'
|
import { defaultNumberInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Number input block', () => {
|
test.describe('Number input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultPaymentInputOptions, InputBlockType } from 'models'
|
import { defaultPaymentInputOptions, InputBlockType } from 'models'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { stripePaymentForm, typebotViewer } from '../../services/selectorUtils'
|
import { stripePaymentForm, typebotViewer } from '../../services/selectorUtils'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Payment input block', () => {
|
test.describe('Payment input block', () => {
|
||||||
test('Can configure Stripe account', async ({ page }) => {
|
test('Can configure Stripe account', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultPhoneInputOptions, InputBlockType } from 'models'
|
import { defaultPhoneInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Phone input block', () => {
|
test.describe('Phone input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { defaultRatingInputOptions, InputBlockType } from 'models'
|
import { defaultRatingInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const boxSvg = `<svg
|
const boxSvg = `<svg
|
||||||
xmlns="http://www.w3.org/2000/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>
|
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||||
</svg>`
|
</svg>`
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
await createTypebots([
|
await createTypebots([
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultTextInputOptions, InputBlockType } from 'models'
|
import { defaultTextInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Text input block', () => {
|
test.describe.parallel('Text input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
import { defaultUrlInputOptions, InputBlockType } from 'models'
|
import { defaultUrlInputOptions, InputBlockType } from 'models'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Url input block', () => {
|
test.describe('Url input block', () => {
|
||||||
test('options should work', async ({ page }) => {
|
test('options should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import {
|
|||||||
} from '../../services/database'
|
} from '../../services/database'
|
||||||
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
|
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Google Analytics block', () => {
|
test.describe('Google Analytics block', () => {
|
||||||
test('its configuration should work', async ({ page }) => {
|
test('its configuration should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { importTypebotInDatabase } from '../../services/database'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe.parallel('Google sheets integration', () => {
|
test.describe.parallel('Google sheets integration', () => {
|
||||||
test('Insert row should work', async ({ page }) => {
|
test('Insert row should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import { importTypebotInDatabase } from '../../services/database'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Send email block', () => {
|
test.describe('Send email block', () => {
|
||||||
test('its configuration should work', async ({ page }) => {
|
test('its configuration should work', async ({ page }) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { createWebhook, importTypebotInDatabase } from '../../services/database'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { HttpMethod } from 'models'
|
import { HttpMethod } from 'models'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Webhook block', () => {
|
test.describe('Webhook block', () => {
|
||||||
test('easy configuration should work', async ({ page }) => {
|
test('easy configuration should work', async ({ page }) => {
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import path from 'path'
|
|||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import { importTypebotInDatabase } from '../../services/database'
|
import { importTypebotInDatabase } from '../../services/database'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Code block', () => {
|
test.describe('Code block', () => {
|
||||||
test('code should trigger', async ({ page }) => {
|
test('code should trigger', async ({ page }) => {
|
||||||
await importTypebotInDatabase(
|
await importTypebotInDatabase(
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import path from 'path'
|
|||||||
import { typebotViewer } from '../../services/selectorUtils'
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
import { importTypebotInDatabase } from '../../services/database'
|
import { importTypebotInDatabase } from '../../services/database'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { mockSessionApiCalls } from 'playwright/services/browser'
|
|
||||||
|
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
|
|
||||||
test.beforeEach(({ page }) => mockSessionApiCalls(page))
|
|
||||||
|
|
||||||
test.describe('Condition block', () => {
|
test.describe('Condition block', () => {
|
||||||
test('its configuration should work', async ({ page }) => {
|
test('its configuration should work', async ({ page }) => {
|
||||||
await importTypebotInDatabase(
|
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