diff --git a/apps/builder/assets/emails/invitationToCollaborate.mjml b/apps/builder/assets/emails/invitationToCollaborate.mjml
index 338df2b867d..3124f51f240 100644
--- a/apps/builder/assets/emails/invitationToCollaborate.mjml
+++ b/apps/builder/assets/emails/invitationToCollaborate.mjml
@@ -20,13 +20,14 @@
- You have been invited to collaborate on a typebot created by ${email}
- From now on you will see this typebot in your dashboard under the "Shared with me "button 👍
+ You have been invited by ${hostEmail} to collaborate on his typebot ${typebotName}
+ From now on you will see this typebot in your dashboard under the his workspace "${workspaceName}" 👍
+ Make sure to log in as ${guestEmail}
- See the typebot
+ Go to typebot
diff --git a/apps/builder/assets/emails/invitationToCollaborate.ts b/apps/builder/assets/emails/invitationToCollaborate.ts
index 4ee39d22bc0..090612b7bba 100644
--- a/apps/builder/assets/emails/invitationToCollaborate.ts
+++ b/apps/builder/assets/emails/invitationToCollaborate.ts
@@ -1,505 +1,32 @@
-export const invitationToCollaborate = (
- email: string,
- typebotUrl: string
-) => `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- You have been invited to
- collaborate on a typebot created
- by ${email}
-
-
-
-
-
-
- From now on you will have access to this
- typebot in their workspace 👍
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+type Props = {
+ workspaceName: string
+ typebotName: string
+ url: string
+ hostEmail: string
+ guestEmail: string
+}
+
+export const invitationToCollaborate = ({guestEmail, hostEmail, typebotName, url, workspaceName}: Props) => `You have been invited by ${hostEmail} to collaborate on his typebot ${typebotName}
From now on you will see this typebot in your dashboard under the his workspace "${workspaceName}" üëç
Make sure to log in as ${guestEmail}
`
diff --git a/apps/builder/assets/emails/workspaceMemberInvitation.mjml b/apps/builder/assets/emails/workspaceMemberInvitation.mjml
new file mode 100644
index 00000000000..873dcb6bf04
--- /dev/null
+++ b/apps/builder/assets/emails/workspaceMemberInvitation.mjml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ You have been invited by ${hostEmail} to collaborate on his workspace "${workspaceName}" as a team member.
+ From now on you will have access to this workspace in your dashboard 👍
+ Make sure to log in as ${guestEmail}
+
+
+
+
+ Go the workspace
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/builder/assets/emails/workspaceMemberInvitation.ts b/apps/builder/assets/emails/workspaceMemberInvitation.ts
new file mode 100644
index 00000000000..6f037900a3c
--- /dev/null
+++ b/apps/builder/assets/emails/workspaceMemberInvitation.ts
@@ -0,0 +1,536 @@
+type Props = {
+ workspaceName: string
+ url: string,
+ hostEmail: string,
+ guestEmail: string
+}
+
+export const workspaceMemberInvitationEmail = ({workspaceName, url, hostEmail, guestEmail}: Props) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You have been invited by
+ ${hostEmail} to collaborate on his
+ workspace "${workspaceName}" as a
+ team member.
+
+
+
+
+
+
+ From now on you will have access
+ to this workspace in your
+ dashboard üëç
+
+
+
+
+
+
+ Make sure to log in as
+ ${guestEmail}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
\ No newline at end of file
diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx
index 0e3b674e647..98f2137cf92 100644
--- a/apps/builder/assets/icons.tsx
+++ b/apps/builder/assets/icons.tsx
@@ -481,3 +481,11 @@ export const EyeOffIcon = (props: IconProps) => (
)
+
+export const AlertIcon = (props: IconProps) => (
+
+
+
+
+
+)
diff --git a/apps/builder/components/analytics/AnalyticsContent.tsx b/apps/builder/components/analytics/AnalyticsContent.tsx
index 248c17b6947..90784559554 100644
--- a/apps/builder/components/analytics/AnalyticsContent.tsx
+++ b/apps/builder/components/analytics/AnalyticsContent.tsx
@@ -2,7 +2,7 @@ import { Flex, Spinner, useDisclosure } from '@chakra-ui/react'
import { StatsCards } from 'components/analytics/StatsCards'
import { Graph } from 'components/shared/Graph'
import { useToast } from 'components/shared/hooks/useToast'
-import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
+import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
import { GraphProvider, GroupsCoordinatesProvider } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { Stats } from 'models'
@@ -49,7 +49,7 @@ export const AnalyticsContent = ({ stats }: { stats?: Stats }) => {
)}
-
+
)
diff --git a/apps/builder/components/dashboard/FolderContent.tsx b/apps/builder/components/dashboard/FolderContent.tsx
index 315446bdfbf..fda8edd9617 100644
--- a/apps/builder/components/dashboard/FolderContent.tsx
+++ b/apps/builder/components/dashboard/FolderContent.tsx
@@ -1,4 +1,5 @@
import { DashboardFolder, WorkspaceRole } from 'db'
+import { env } from 'utils'
import {
Flex,
Heading,
@@ -160,9 +161,13 @@ export const FolderContent = ({ folder }: Props) => {
return (
- {typebots && !isTypebotLoading && user && folder === null && (
-
- )}
+ {typebots &&
+ !isTypebotLoading &&
+ user &&
+ folder === null &&
+ env('E2E_TEST') !== 'true' && (
+
+ )}
{folder?.name}
diff --git a/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx b/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx
index a3ead02c48b..e3da3bec121 100644
--- a/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx
+++ b/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx
@@ -1,7 +1,9 @@
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons'
-import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
-import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
+import {
+ LimitReached,
+ ChangePlanModal,
+} from 'components/shared/modals/ChangePlanModal'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { isFreePlan } from 'services/workspace'
@@ -26,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
Create a folder
{isFreePlan(workspace) && Pro }
- {
+ const { workspace, refreshWorkspace } = useWorkspace()
+
+ if (!workspace) return null
+ return (
+
+
+ refreshWorkspace({
+ plan: Plan.FREE,
+ additionalChatsIndex: 0,
+ additionalStorageIndex: 0,
+ })
+ }
+ />
+
+ {workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.OFFERED && (
+
+ )}
+ {workspace.stripeId && }
+
+ )
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/CurrentSubscriptionContent.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/CurrentSubscriptionContent.tsx
new file mode 100644
index 00000000000..b0962ba5044
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/CurrentSubscriptionContent.tsx
@@ -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
+ return (
+
+
+ Current workspace subscription:
+
+
+
+ {(plan === Plan.STARTER || plan === Plan.PRO) && stripeId && (
+ <>
+
+
+ Need to change payment method or billing information? Head over to
+ your billing portal:
+
+ setIsRedirectingToBillingPortal(true)}
+ isLoading={isRedirectingToBillingPortal}
+ >
+ Billing Portal
+
+
+
+
+ Cancel my subscription
+
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/InvoicesList.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/InvoicesList.tsx
new file mode 100644
index 00000000000..1fa753cf619
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/InvoicesList.tsx
@@ -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 (
+
+ Invoices
+ {invoices.length === 0 && !isLoading ? (
+ No invoices found for this workspace.
+ ) : (
+
+
+
+
+
+ #
+ Paid at
+ Subtotal
+
+
+
+
+ {invoices?.map((invoice) => (
+
+
+
+
+ {invoice.id}
+ {new Date(invoice.date * 1000).toDateString()}
+ {getFormattedPrice(invoice.amount, invoice.currency)}
+
+ }
+ variant="outline"
+ href={invoice.url}
+ isExternal
+ aria-label={'Download invoice'}
+ />
+
+
+ ))}
+ {isLoading &&
+ Array.from({ length: 3 }).map((_, idx) => (
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
+
+const getFormattedPrice = (amount: number, currency: string) => {
+ const formatter = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ })
+
+ return formatter.format(amount / 100)
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/UsageContent.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/UsageContent.tsx
new file mode 100644
index 00000000000..8385aa85041
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/UsageContent.tsx
@@ -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 (
+
+ Usage
+
+
+
+
+ Chats
+
+ {chatsPercentage >= 80 && (
+
+ Your typebots are popular! You will soon reach your plan's
+ chats limit. 🚀
+
+
+ Make sure to update your plan to increase
+ this limit and continue chatting with your users.
+
+ }
+ >
+
+
+
+
+ )}
+
+ (resets on 1st of every month)
+
+
+
+
+ {parseNumberWithCommas(totalChatsUsed)}
+
+ / {parseNumberWithCommas(workspaceChatsLimit)}
+
+
+
+ = workspaceChatsLimit ? 'red' : 'blue'}
+ />
+
+ {workspace.plan !== Plan.FREE && (
+
+
+
+
+ Storage
+
+ {storagePercentage >= 80 && (
+
+ Your typebots are popular! You will soon reach your plan's
+ storage limit. 🚀
+
+
+ Make sure to update your plan in order to
+ continue collecting uploaded files. You can also{' '}
+ delete files to free up space.
+
+ }
+ >
+
+
+
+
+ )}
+
+
+
+
+ {storageToReadable(totalStorageUsed)}
+
+ / {workspaceStorageLimit} GB
+
+
+ = workspaceStorageLimitGigabites
+ ? 'red'
+ : 'blue'
+ }
+ rounded="full"
+ hasStripe
+ isIndeterminate={isLoading}
+ />
+
+ )}
+
+ )
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/helpers.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/helpers.ts
new file mode 100644
index 00000000000..6644ea5e5b1
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/helpers.ts
@@ -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'
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/index.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/index.ts
new file mode 100644
index 00000000000..2ce5fe4a63a
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/index.ts
@@ -0,0 +1 @@
+export { UsageContent } from './UsageContent'
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/useUsage.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/useUsage.ts
new file mode 100644
index 00000000000..b37ca74f28f
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/useUsage.ts
@@ -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,
+ }
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/index.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/index.ts
new file mode 100644
index 00000000000..27b9ed52734
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/index.ts
@@ -0,0 +1 @@
+export { BillingContent } from './BillingContent'
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/cancelSubscriptionQuery.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/cancelSubscriptionQuery.ts
new file mode 100644
index 00000000000..705249839ac
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/cancelSubscriptionQuery.ts
@@ -0,0 +1,7 @@
+import { sendRequest } from 'utils'
+
+export const cancelSubscriptionQuery = (stripeId: string) =>
+ sendRequest({
+ url: `api/stripe/subscription?stripeId=${stripeId}`,
+ method: 'DELETE',
+ })
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/redirectToBillingPortal.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/redirectToBillingPortal.ts
new file mode 100644
index 00000000000..f40dd5e542c
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/redirectToBillingPortal.ts
@@ -0,0 +1,7 @@
+import { sendRequest } from 'utils'
+
+export const redirectToBillingPortal = ({
+ workspaceId,
+}: {
+ workspaceId: string
+}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/useInvoicesQuery.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/useInvoicesQuery.ts
new file mode 100644
index 00000000000..184dba0dab5
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/queries/useInvoicesQuery.ts
@@ -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,
+ }
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx
deleted file mode 100644
index f24acb379c3..00000000000
--- a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx
+++ /dev/null
@@ -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 (
-
-
- Current workspace subscription:
-
-
- {workspace &&
- !([Plan.TEAM, Plan.LIFETIME, Plan.OFFERED] as Plan[]).includes(
- workspace.plan
- ) && (
-
- {workspace?.plan === Plan.FREE && (
-
- Upgrade to Pro plan
-
- )}
- {workspace?.plan !== Plan.TEAM && (
-
- Upgrade to Team plan
-
- )}
-
- )}
- {workspace?.stripeId && (
- <>
-
- To manage your subscription and download invoices, head over to your
- Stripe portal:
-
-
- }
- >
- Stripe Portal
-
- >
- )}
-
- )
-}
-
-const PlanTag = ({ plan }: { plan?: Plan }) => {
- switch (plan) {
- case Plan.TEAM: {
- return Team
- }
- case Plan.LIFETIME:
- case Plan.OFFERED:
- case Plan.PRO: {
- return Personal Pro
- }
- default: {
- return Free
- }
- }
-}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx
index 459f8b72c4c..cba5f9ac0fb 100644
--- a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx
@@ -2,7 +2,7 @@ import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
import { UnlockPlanInfo } from 'components/shared/Info'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
-import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
+import { WorkspaceInvitation, WorkspaceRole } from 'db'
import React from 'react'
import {
deleteInvitation,
@@ -13,6 +13,7 @@ import {
useMembers,
} from 'services/workspace'
import { AddMemberForm } from './AddMemberForm'
+import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem'
export const MembersList = () => {
@@ -78,14 +79,19 @@ export const MembersList = () => {
})
}
+ const canInviteNewMember = checkCanInviteMember({
+ plan: workspace?.plan,
+ currentMembersCount: [...(members ?? []), ...(invitations ?? [])].length,
+ })
+
return (
-
- {workspace?.plan !== Plan.TEAM && (
+
+ {!canInviteNewMember && (
)}
{workspace?.id && canEdit && (
@@ -94,7 +100,7 @@ export const MembersList = () => {
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
- isLocked={workspace.plan !== Plan.TEAM}
+ isLocked={!canInviteNewMember}
/>
)}
{members?.map((member) => (
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/helpers.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/helpers.ts
new file mode 100644
index 00000000000..6c14ae07679
--- /dev/null
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/helpers.ts
@@ -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
+}
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx
index 26bfa213605..a405695a0d8 100644
--- a/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx
@@ -18,7 +18,7 @@ import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { User, Workspace } from 'db'
import { useState } from 'react'
-import { BillingForm } from './BillingForm'
+import { BillingContent } from './BillingContent'
import { MembersList } from './MembersList'
import { MyAccountForm } from './MyAccountForm'
import { EditorSettingsForm } from './EditorSettingsForm'
@@ -50,13 +50,12 @@ export const WorkspaceSettingsModal = ({
return (
-
+
@@ -134,7 +133,7 @@ export const WorkspaceSettingsModal = ({
justifyContent="flex-start"
pl="4"
>
- Billing
+ Billing & Usage
)}
@@ -174,7 +173,7 @@ const SettingsContent = ({
case 'members':
return
case 'billing':
- return
+ return
default:
return null
}
diff --git a/apps/builder/components/results/ResultsContent.tsx b/apps/builder/components/results/ResultsContent.tsx
index 44fda051030..817eea3a910 100644
--- a/apps/builder/components/results/ResultsContent.tsx
+++ b/apps/builder/components/results/ResultsContent.tsx
@@ -1,10 +1,8 @@
import { Stack } from '@chakra-ui/react'
import { SubmissionsTable } from 'components/results/ResultsTable'
import React, { useState } from 'react'
-import { UnlockPlanInfo } from 'components/shared/Info'
import { LogsModal } from './LogsModal'
import { useTypebot } from 'contexts/TypebotContext'
-import { Plan } from 'db'
import { useResults } from 'contexts/ResultsProvider'
import { ResultModal } from './ResultModal'
@@ -14,7 +12,6 @@ export const ResultsContent = () => {
fetchMore,
hasMore,
resultHeader,
- totalHiddenResults,
tableData,
} = useResults()
const { typebot, publishedTypebot } = useTypebot()
@@ -46,13 +43,6 @@ export const ResultsContent = () => {
overflow="scroll"
w="full"
>
- {totalHiddenResults && (
-
- )}
{publishedTypebot && (
0 && selectedResultsId.length === results?.length
- ? totalResults - (totalHiddenResults ?? 0)
+ ? totalResults
: selectedResultsId.length
const deleteResults = async () => {
@@ -87,9 +86,7 @@ export const ResultsActionButtons = ({
const exportResultsToCSV = async () => {
setIsExportLoading(true)
- const isSelectAll =
- totalSelected === 0 ||
- totalSelected === totalResults - (totalHiddenResults ?? 0)
+ const isSelectAll = totalSelected === 0 || totalSelected === totalResults
const dataToUnparse = isSelectAll
? await getAllTableData()
diff --git a/apps/builder/components/settings/GeneralSettingsForm.tsx b/apps/builder/components/settings/GeneralSettingsForm.tsx
index 75e387a826d..b60e4352807 100644
--- a/apps/builder/components/settings/GeneralSettingsForm.tsx
+++ b/apps/builder/components/settings/GeneralSettingsForm.tsx
@@ -6,7 +6,7 @@ import {
Tag,
useDisclosure,
} from '@chakra-ui/react'
-import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
+import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { GeneralSettings } from 'models'
@@ -56,7 +56,7 @@ export const GeneralSettingsForm = ({
return (
-
+
diff --git a/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx b/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
new file mode 100644
index 00000000000..d07c99add6e
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
@@ -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 (
+
+
+
+ handlePayClick({ ...props, plan: Plan.STARTER })
+ }
+ />
+
+ handlePayClick({ ...props, plan: Plan.PRO })}
+ />
+
+
+ Need custom limits? Specific features?{' '}
+
+ Let me know
+
+ .
+
+
+ )
+}
diff --git a/apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx b/apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
new file mode 100644
index 00000000000..0e778c0b1c1
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
@@ -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
+}
+
+export const ProPlanContent = ({
+ initialChatsLimitIndex,
+ initialStorageLimitIndex,
+ onPayClick,
+}: ProPlanContentProps) => {
+ const { workspace } = useWorkspace()
+ const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
+ useState()
+ const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
+ useState()
+ 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 (
+
+
+
+ Most popular
+
+
+
+
+
+ Upgrade to Pro
+
+ For agencies & growing startups.
+
+
+
+ {formatPrice(
+ computePrice(
+ Plan.PRO,
+ selectedChatsLimitIndex ?? 0,
+ selectedStorageLimitIndex ?? 0
+ ) ?? NaN
+ )}
+ / month
+
+
+
+ }
+ hasArrow
+ placement="top"
+ >
+
+ Everything in Starter
+
+
+ , plus:
+
+
+
+
+ }
+ size="sm"
+ isLoading={selectedChatsLimitIndex === undefined}
+ >
+ {parseNumberWithCommas(
+ chatsLimit.PRO.totalIncluded +
+ chatsLimit.PRO.increaseStep.amount *
+ (selectedChatsLimitIndex ?? 0)
+ )}
+
+
+ {selectedChatsLimitIndex !== 0 && (
+ setSelectedChatsLimitIndex(0)}>
+ {parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
+
+ )}
+ {selectedChatsLimitIndex !== 1 && (
+ setSelectedChatsLimitIndex(1)}>
+ {parseNumberWithCommas(
+ chatsLimit.PRO.totalIncluded +
+ chatsLimit.PRO.increaseStep.amount
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 2 && (
+ setSelectedChatsLimitIndex(2)}>
+ {parseNumberWithCommas(
+ chatsLimit.PRO.totalIncluded +
+ chatsLimit.PRO.increaseStep.amount * 2
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 3 && (
+ setSelectedChatsLimitIndex(3)}>
+ {parseNumberWithCommas(
+ chatsLimit.PRO.totalIncluded +
+ chatsLimit.PRO.increaseStep.amount * 3
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 4 && (
+ setSelectedChatsLimitIndex(4)}>
+ {parseNumberWithCommas(
+ chatsLimit.PRO.totalIncluded +
+ chatsLimit.PRO.increaseStep.amount * 4
+ )}
+
+ )}
+
+ {' '}
+ chats/mo
+
+
+ A chat is counted whenever a user starts a discussion. It is
+ independant of the number of messages he sends and receives.
+
+ ,
+
+
+
+ }
+ size="sm"
+ isLoading={selectedStorageLimitIndex === undefined}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded +
+ storageLimit.PRO.increaseStep.amount *
+ (selectedStorageLimitIndex ?? 0)
+ )}
+
+
+ {selectedStorageLimitIndex !== 0 && (
+ setSelectedStorageLimitIndex(0)}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 1 && (
+ setSelectedStorageLimitIndex(1)}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded +
+ storageLimit.PRO.increaseStep.amount
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 2 && (
+ setSelectedStorageLimitIndex(2)}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded +
+ storageLimit.PRO.increaseStep.amount * 2
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 3 && (
+ setSelectedStorageLimitIndex(3)}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded +
+ storageLimit.PRO.increaseStep.amount * 3
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 4 && (
+ setSelectedStorageLimitIndex(4)}
+ >
+ {parseNumberWithCommas(
+ storageLimit.PRO.totalIncluded +
+ storageLimit.PRO.increaseStep.amount * 4
+ )}
+
+ )}
+
+ {' '}
+ GB of storage
+
+
+ You accumulate storage for every file that your user upload
+ into your bot. If you delete the result, it will free up the
+ space.
+
+ ,
+ 'Custom domains',
+ 'In-depth analytics',
+ ]}
+ />
+
+ {getButtonLabel()}
+
+
+
+
+ )
+}
diff --git a/apps/builder/components/shared/ChangePlanForm/StarterPlanContent.tsx b/apps/builder/components/shared/ChangePlanForm/StarterPlanContent.tsx
new file mode 100644
index 00000000000..291a4f36d5e
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/StarterPlanContent.tsx
@@ -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
+}
+
+export const StarterPlanContent = ({
+ initialChatsLimitIndex,
+ initialStorageLimitIndex,
+ onPayClick,
+}: StarterPlanContentProps) => {
+ const { workspace } = useWorkspace()
+ const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
+ useState()
+ const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
+ useState()
+ 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 (
+
+
+
+ Upgrade to Starter
+
+ For individuals & small businesses.
+
+ {formatPrice(
+ computePrice(
+ Plan.STARTER,
+ selectedChatsLimitIndex ?? 0,
+ selectedStorageLimitIndex ?? 0
+ ) ?? NaN
+ )}
+ / month
+
+
+
+
+ }
+ size="sm"
+ isLoading={selectedChatsLimitIndex === undefined}
+ >
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded +
+ chatsLimit.STARTER.increaseStep.amount *
+ (selectedChatsLimitIndex ?? 0)
+ )}
+
+
+ {selectedChatsLimitIndex !== 0 && (
+ setSelectedChatsLimitIndex(0)}>
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 1 && (
+ setSelectedChatsLimitIndex(1)}>
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded +
+ chatsLimit.STARTER.increaseStep.amount
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 2 && (
+ setSelectedChatsLimitIndex(2)}>
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded +
+ chatsLimit.STARTER.increaseStep.amount * 2
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 3 && (
+ setSelectedChatsLimitIndex(3)}>
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded +
+ chatsLimit.STARTER.increaseStep.amount * 3
+ )}
+
+ )}
+ {selectedChatsLimitIndex !== 4 && (
+ setSelectedChatsLimitIndex(4)}>
+ {parseNumberWithCommas(
+ chatsLimit.STARTER.totalIncluded +
+ chatsLimit.STARTER.increaseStep.amount * 4
+ )}
+
+ )}
+
+ {' '}
+ chats/mo
+
+
+ A chat is counted whenever a user starts a discussion. It is
+ independant of the number of messages he sends and receives.
+
+ ,
+
+
+
+ }
+ size="sm"
+ isLoading={selectedStorageLimitIndex === undefined}
+ >
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded +
+ storageLimit.STARTER.increaseStep.amount *
+ (selectedStorageLimitIndex ?? 0)
+ )}
+
+
+ {selectedStorageLimitIndex !== 0 && (
+ setSelectedStorageLimitIndex(0)}>
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 1 && (
+ setSelectedStorageLimitIndex(1)}>
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded +
+ storageLimit.STARTER.increaseStep.amount
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 2 && (
+ setSelectedStorageLimitIndex(2)}>
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded +
+ storageLimit.STARTER.increaseStep.amount * 2
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 3 && (
+ setSelectedStorageLimitIndex(3)}>
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded +
+ storageLimit.STARTER.increaseStep.amount * 3
+ )}
+
+ )}
+ {selectedStorageLimitIndex !== 4 && (
+ setSelectedStorageLimitIndex(4)}>
+ {parseNumberWithCommas(
+ storageLimit.STARTER.totalIncluded +
+ storageLimit.STARTER.increaseStep.amount * 4
+ )}
+
+ )}
+
+ {' '}
+ GB of storage
+
+
+ You accumulate storage for every file that your user upload into
+ your bot. If you delete the result, it will free up the space.
+
+ ,
+ 'Branding removed',
+ 'File upload input block',
+ 'Create folders',
+ ]}
+ />
+
+
+ {getButtonLabel()}
+
+
+ )
+}
diff --git a/apps/builder/components/shared/ChangePlanForm/components/FeaturesList.tsx b/apps/builder/components/shared/ChangePlanForm/components/FeaturesList.tsx
new file mode 100644
index 00000000000..2ed9ab3b4aa
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/components/FeaturesList.tsx
@@ -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) => (
+
+ {features.map((feat, idx) => (
+
+
+ {feat}
+
+ ))}
+
+)
diff --git a/apps/builder/components/shared/ChangePlanForm/helpers.ts b/apps/builder/components/shared/ChangePlanForm/helpers.ts
new file mode 100644
index 00000000000..a9d05acb7d1
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/helpers.ts
@@ -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)
+}
diff --git a/apps/builder/components/shared/ChangePlanForm/index.ts b/apps/builder/components/shared/ChangePlanForm/index.ts
new file mode 100644
index 00000000000..6d5a904477c
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/index.ts
@@ -0,0 +1 @@
+export { ChangePlanForm } from './ChangePlanForm'
diff --git a/apps/builder/services/stripe.ts b/apps/builder/components/shared/ChangePlanForm/queries/updatePlan.tsx
similarity index 50%
rename from apps/builder/services/stripe.ts
rename to apps/builder/components/shared/ChangePlanForm/queries/updatePlan.tsx
index 9fbaf5dba28..8f1e623be4d 100644
--- a/apps/builder/services/stripe.ts
+++ b/apps/builder/components/shared/ChangePlanForm/queries/updatePlan.tsx
@@ -1,55 +1,61 @@
-import { Plan, User } from 'db'
import { loadStripe } from '@stripe/stripe-js/pure'
+import { Plan, User } from 'db'
import { env, isDefined, isEmpty, sendRequest } from 'utils'
+import { guessIfUserIsEuropean } from '../helpers'
-type Props = {
+type UpgradeProps = {
user: User
- customerId?: string
- currency: 'usd' | 'eur'
- plan: 'pro' | 'team'
+ stripeId?: string
+ plan: Plan
workspaceId: string
+ additionalChats: number
+ additionalStorage: number
}
export const pay = async ({
- customerId,
+ stripeId,
...props
-}: Props): Promise<{ newPlan: Plan } | undefined | void> =>
- isDefined(customerId)
- ? updatePlan({ ...props, customerId })
+}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> =>
+ isDefined(stripeId)
+ ? updatePlan({ ...props, stripeId })
: redirectToCheckout(props)
-const updatePlan = async ({
- customerId,
+export const updatePlan = async ({
+ stripeId,
plan,
workspaceId,
- currency,
-}: Omit): Promise<{ newPlan: Plan } | undefined> => {
+ additionalChats,
+ additionalStorage,
+}: Omit): Promise<{ newPlan: Plan } | undefined> => {
const { data, error } = await sendRequest<{ message: string }>({
- method: 'POST',
- url: '/api/stripe/update-subscription',
- body: { workspaceId, plan, customerId, currency },
+ method: 'PUT',
+ url: '/api/stripe/subscription',
+ body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
})
if (error || !data) return
- return { newPlan: plan === 'team' ? Plan.TEAM : Plan.PRO }
+ return { newPlan: plan }
}
-const redirectToCheckout = async ({
+export const redirectToCheckout = async ({
user,
- currency,
plan,
workspaceId,
-}: Omit) => {
+ additionalChats,
+ additionalStorage,
+}: Omit) => {
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST',
- url: '/api/stripe/checkout',
+ url: '/api/stripe/subscription',
body: {
email: user.email,
- currency,
+ currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
plan,
workspaceId,
href: location.origin + location.pathname,
+ additionalChats,
+ additionalStorage,
},
})
if (error || !data) return
diff --git a/apps/builder/components/shared/ChangePlanForm/queries/useCurrentSubscriptionInfo.ts b/apps/builder/components/shared/ChangePlanForm/queries/useCurrentSubscriptionInfo.ts
new file mode 100644
index 00000000000..4af314e9cb2
--- /dev/null
+++ b/apps/builder/components/shared/ChangePlanForm/queries/useCurrentSubscriptionInfo.ts
@@ -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,
+ }
+}
diff --git a/apps/builder/components/shared/Info.tsx b/apps/builder/components/shared/Info.tsx
index 69c4e9f175e..9e6ad189380 100644
--- a/apps/builder/components/shared/Info.tsx
+++ b/apps/builder/components/shared/Info.tsx
@@ -7,10 +7,9 @@ import {
Text,
useDisclosure,
} from '@chakra-ui/react'
-import { Plan } from 'db'
import React from 'react'
-import { UpgradeModal } from './modals/UpgradeModal'
-import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
+import { ChangePlanModal } from './modals/ChangePlanModal'
+import { LimitReached } from './modals/ChangePlanModal'
export const Info = (props: AlertProps) => (
@@ -27,30 +26,34 @@ export const UnlockPlanInfo = ({
contentLabel,
buttonLabel = 'More info',
type,
- plan = Plan.PRO,
+ ...props
}: {
- contentLabel: string
+ contentLabel: React.ReactNode
buttonLabel?: string
type?: LimitReached
- plan: Plan
-}) => {
+} & AlertProps) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
{contentLabel}
-
+
{buttonLabel}
-
+
)
}
diff --git a/apps/builder/components/shared/MoreInfoTooltip.tsx b/apps/builder/components/shared/MoreInfoTooltip.tsx
index d5098b7fb2e..d4969e31376 100644
--- a/apps/builder/components/shared/MoreInfoTooltip.tsx
+++ b/apps/builder/components/shared/MoreInfoTooltip.tsx
@@ -8,7 +8,7 @@ type Props = {
export const MoreInfoTooltip = ({ children }: Props) => {
return (
-
+
diff --git a/apps/builder/components/shared/PlanTag.tsx b/apps/builder/components/shared/PlanTag.tsx
new file mode 100644
index 00000000000..d56972a66ea
--- /dev/null
+++ b/apps/builder/components/shared/PlanTag.tsx
@@ -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 (
+
+ Pro
+
+ )
+ }
+ case Plan.OFFERED:
+ case Plan.STARTER: {
+ return (
+
+ Starter
+
+ )
+ }
+ default: {
+ return (
+
+ Free
+
+ )
+ }
+ }
+}
diff --git a/apps/builder/components/shared/buttons/PublishButton.tsx b/apps/builder/components/shared/buttons/PublishButton.tsx
index 16dcdc876a3..25da59a8497 100644
--- a/apps/builder/components/shared/buttons/PublishButton.tsx
+++ b/apps/builder/components/shared/buttons/PublishButton.tsx
@@ -15,14 +15,12 @@ import {
import { ChevronLeftIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
-import { Plan } from 'db'
import { InputBlockType } from 'models'
import { useRouter } from 'next/router'
import { timeSince } from 'services/utils'
import { isFreePlan } from 'services/workspace'
import { isNotDefined } from 'utils'
-import { UpgradeModal } from '../modals/UpgradeModal'
-import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
+import { LimitReached, ChangePlanModal } from '../modals/ChangePlanModal'
export const PublishButton = (props: ButtonProps) => {
const { workspace } = useWorkspace()
@@ -50,8 +48,7 @@ export const PublishButton = (props: ButtonProps) => {
return (
- {
+export const UpgradeButton = ({ type, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
return (
@@ -19,7 +18,7 @@ export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
onClick={onOpen}
>
{props.children ?? 'Upgrade'}
-
+
)
}
diff --git a/apps/builder/components/shared/buttons/UploadButton.tsx b/apps/builder/components/shared/buttons/UploadButton.tsx
index d9de00f5a86..8dcbefb5129 100644
--- a/apps/builder/components/shared/buttons/UploadButton.tsx
+++ b/apps/builder/components/shared/buttons/UploadButton.tsx
@@ -29,7 +29,7 @@ export const UploadButton = ({
},
],
})
- if (urls.length) onFileUploaded(urls[0])
+ if (urls.length && urls[0]) onFileUploaded(urls[0])
setIsUploading(false)
}
diff --git a/apps/builder/components/shared/hooks/useToast.tsx b/apps/builder/components/shared/hooks/useToast.tsx
index 6e05979f211..c260095a607 100644
--- a/apps/builder/components/shared/hooks/useToast.tsx
+++ b/apps/builder/components/shared/hooks/useToast.tsx
@@ -7,12 +7,14 @@ export const useToast = () => {
title,
description,
status = 'error',
+ ...props
}: UseToastOptions) => {
toast({
position: 'bottom-right',
description,
title,
status,
+ ...props,
})
}
diff --git a/apps/builder/components/shared/modals/ChangePlanModal.tsx b/apps/builder/components/shared/modals/ChangePlanModal.tsx
new file mode 100644
index 00000000000..0a2e7e523bc
--- /dev/null
+++ b/apps/builder/components/shared/modals/ChangePlanModal.tsx
@@ -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 (
+
+
+
+
+ {type && (
+ You need to upgrade your plan in order to {type}
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/components/shared/modals/UpgradeModal/ActionButton.tsx b/apps/builder/components/shared/modals/UpgradeModal/ActionButton.tsx
deleted file mode 100644
index 438d10474df..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/ActionButton.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Button, ButtonProps } from '@chakra-ui/react'
-import * as React from 'react'
-
-export const ActionButton = (props: ButtonProps) => (
-
-)
diff --git a/apps/builder/components/shared/modals/UpgradeModal/Card.tsx b/apps/builder/components/shared/modals/UpgradeModal/Card.tsx
deleted file mode 100644
index 4498a77ab75..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/Card.tsx
+++ /dev/null
@@ -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 (
-
- {isPopular && Popular }
- {children}
-
- )
-}
diff --git a/apps/builder/components/shared/modals/UpgradeModal/CardBadge.tsx b/apps/builder/components/shared/modals/UpgradeModal/CardBadge.tsx
deleted file mode 100644
index b9932b7ae1f..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/CardBadge.tsx
+++ /dev/null
@@ -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 (
-
-
- {children}
-
-
- )
-}
diff --git a/apps/builder/components/shared/modals/UpgradeModal/PricingCard.tsx b/apps/builder/components/shared/modals/UpgradeModal/PricingCard.tsx
deleted file mode 100644
index dd077aa7fbf..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/PricingCard.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {name}
-
-
-
-
- {price}
-
-
- / mo
-
-
-
- {features.map((feature, index) => (
-
-
- {feature}
-
- ))}
-
- {button}
-
- )
-}
diff --git a/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx b/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx
deleted file mode 100644
index 7ffc6a11692..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {plan === Plan.PRO ? (
-
- ) : (
-
- )}
-
-
-
-
-
- Cancel
-
-
- Upgrade
-
-
-
-
-
- )
-}
-
-const PersonalProPlanContent = ({
- currency,
- type,
-}: {
- currency: 'eur' | 'usd'
- type?: LimitReached
-}) => {
- return (
-
- You need to upgrade your plan in order to {type}
-
-
- Upgrade to Personal Pro {' '}
- plan
-
- For solo creators who want to do even more.
-
- {currency === 'eur' ? '39€' : '$39'}
- / month
-
- Everything in Personal, plus:
-
-
- )
-}
-
-const TeamPlanContent = ({
- currency,
- type,
-}: {
- currency: 'eur' | 'usd'
- type?: LimitReached
-}) => {
- return (
-
- You need to upgrade your plan in order to {type}
-
-
- Upgrade to Team plan
-
- For teams to build typebots together in one spot.
-
- {currency === 'eur' ? '99€' : '$99'}
- / month
-
-
-
- }
- hasArrow
- placement="top"
- >
-
- Everything in Pro
-
-
- , plus:
-
-
-
- )
-}
-
-const FeatureList = ({
- features,
- ...props
-}: { features: string[] } & ListProps) => (
-
- {features.map((feat) => (
-
-
- {feat}
-
- ))}
-
-)
diff --git a/apps/builder/components/shared/modals/UpgradeModal/index.tsx b/apps/builder/components/shared/modals/UpgradeModal/index.tsx
deleted file mode 100644
index e8b972d56f6..00000000000
--- a/apps/builder/components/shared/modals/UpgradeModal/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { UpgradeModal } from './UpgradeModal'
diff --git a/apps/builder/components/shared/modals/index.ts b/apps/builder/components/shared/modals/index.ts
new file mode 100644
index 00000000000..a33f48a9c4a
--- /dev/null
+++ b/apps/builder/components/shared/modals/index.ts
@@ -0,0 +1 @@
+export { ChangePlanModal } from './ChangePlanModal'
diff --git a/apps/builder/contexts/ResultsProvider.tsx b/apps/builder/contexts/ResultsProvider.tsx
index 3c963332cbd..683e0917a9d 100644
--- a/apps/builder/contexts/ResultsProvider.tsx
+++ b/apps/builder/contexts/ResultsProvider.tsx
@@ -15,7 +15,6 @@ const resultsContext = createContext<{
hasMore: boolean
resultHeader: ResultHeaderCell[]
totalResults: number
- totalHiddenResults?: number
tableData: TableData[]
onDeleteResults: (totalResultsDeleted: number) => void
fetchMore: () => void
@@ -33,14 +32,12 @@ export const ResultsProvider = ({
workspaceId,
typebotId,
totalResults,
- totalHiddenResults,
onDeleteResults,
}: {
children: ReactNode
workspaceId: string
typebotId: string
totalResults: number
- totalHiddenResults?: number
onDeleteResults: (totalResultsDeleted: number) => void
}) => {
const { publishedTypebot, linkedTypebots } = useTypebot()
@@ -84,7 +81,6 @@ export const ResultsProvider = ({
tableData,
resultHeader,
totalResults,
- totalHiddenResults,
onDeleteResults,
fetchMore,
mutate,
diff --git a/apps/builder/libs/theme.ts b/apps/builder/libs/theme.ts
index 69b6aca17d0..a7bfe83d3cf 100644
--- a/apps/builder/libs/theme.ts
+++ b/apps/builder/libs/theme.ts
@@ -98,6 +98,12 @@ const components = {
},
},
},
+ Tooltip: {
+ defaultProps: {
+ rounded: 'md',
+ hasArrow: true,
+ },
+ },
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 5db0db69f9a..b8fdbe6b3fb 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -9,7 +9,7 @@
"start": "next start",
"lint": "next lint",
"test": "pnpm playwright test",
- "test:open": "PWDEBUG=1 pnpm playwright test"
+ "test:open": "NO_RETRIES=1 pnpm playwright test --debug"
},
"dependencies": {
"@chakra-ui/css-reset": "2.0.7",
diff --git a/apps/builder/pages/_document.tsx b/apps/builder/pages/_document.tsx
index 486cf9fee8b..ca2bcfbad7b 100644
--- a/apps/builder/pages/_document.tsx
+++ b/apps/builder/pages/_document.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @next/next/no-sync-scripts */
import Document, {
Html,
Head,
@@ -22,7 +23,6 @@ class MyDocument extends Document {
rel="stylesheet"
/>
- {/* eslint-disable-next-line @next/next/no-sync-scripts */}
diff --git a/apps/builder/pages/api/auth/[...nextauth].ts b/apps/builder/pages/api/auth/[...nextauth].ts
index 0ef1add7be9..0d8803939f4 100644
--- a/apps/builder/pages/api/auth/[...nextauth].ts
+++ b/apps/builder/pages/api/auth/[...nextauth].ts
@@ -12,6 +12,7 @@ import { withSentry } from '@sentry/nextjs'
import { CustomAdapter } from './adapter'
import { User } from 'db'
import { env, isNotEmpty } from 'utils'
+import { mockedUser } from 'services/api/utils'
const providers: Provider[] = []
@@ -98,6 +99,14 @@ if (
}
const handler = (req: NextApiRequest, res: NextApiResponse) => {
+ if (
+ req.method === 'GET' &&
+ req.url === '/api/auth/session' &&
+ env('E2E_TEST') === 'true'
+ ) {
+ res.send({ user: mockedUser })
+ return
+ }
if (req.method === 'HEAD') {
res.status(200)
return
diff --git a/apps/builder/pages/api/auth/adapter.ts b/apps/builder/pages/api/auth/adapter.ts
index ae52a283376..bd2513bb129 100644
--- a/apps/builder/pages/api/auth/adapter.ts
+++ b/apps/builder/pages/api/auth/adapter.ts
@@ -52,10 +52,11 @@ export function CustomAdapter(p: PrismaClient): Adapter {
name: data.name
? `${data.name}'s workspace`
: `My workspace`,
- plan:
- process.env.ADMIN_EMAIL === data.email
- ? Plan.TEAM
- : Plan.FREE,
+ ...(process.env.ADMIN_EMAIL === data.email
+ ? { plan: Plan.LIFETIME }
+ : {
+ plan: Plan.FREE,
+ }),
},
},
},
diff --git a/apps/builder/pages/api/stripe/customer-portal.ts b/apps/builder/pages/api/stripe/billing-portal.ts
similarity index 89%
rename from apps/builder/pages/api/stripe/customer-portal.ts
rename to apps/builder/pages/api/stripe/billing-portal.ts
index 1f9510b1c80..ea67308434f 100644
--- a/apps/builder/pages/api/stripe/customer-portal.ts
+++ b/apps/builder/pages/api/stripe/billing-portal.ts
@@ -15,13 +15,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
- const workspaceId = req.query.workspaceId as string | undefined
- if (!workspaceId) return badRequest(res)
+ const stripeId = req.query.stripeId as string | undefined
+ if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
- id: workspaceId,
+ stripeId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
diff --git a/apps/builder/pages/api/stripe/checkout.ts b/apps/builder/pages/api/stripe/checkout.ts
deleted file mode 100644
index 7139499c0d8..00000000000
--- a/apps/builder/pages/api/stripe/checkout.ts
+++ /dev/null
@@ -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)
diff --git a/apps/builder/pages/api/stripe/invoices.ts b/apps/builder/pages/api/stripe/invoices.ts
new file mode 100644
index 00000000000..de8ab82aa47
--- /dev/null
+++ b/apps/builder/pages/api/stripe/invoices.ts
@@ -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)
diff --git a/apps/builder/pages/api/stripe/subscription.ts b/apps/builder/pages/api/stripe/subscription.ts
new file mode 100644
index 00000000000..d4d309f461f
--- /dev/null
+++ b/apps/builder/pages/api/stripe/subscription.ts
@@ -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)
diff --git a/apps/builder/pages/api/stripe/update-subscription.ts b/apps/builder/pages/api/stripe/update-subscription.ts
deleted file mode 100644
index 994b5b1ac37..00000000000
--- a/apps/builder/pages/api/stripe/update-subscription.ts
+++ /dev/null
@@ -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)
diff --git a/apps/builder/pages/api/stripe/webhook.ts b/apps/builder/pages/api/stripe/webhook.ts
index 70ffa9be5a8..c73cd0aa41d 100644
--- a/apps/builder/pages/api/stripe/webhook.ts
+++ b/apps/builder/pages/api/stripe/webhook.ts
@@ -4,7 +4,6 @@ import Stripe from 'stripe'
import Cors from 'micro-cors'
import { buffer } from 'micro'
import prisma from 'libs/prisma'
-import { Plan } from 'db'
import { withSentry } from '@sentry/nextjs'
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
@@ -40,30 +39,29 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
- const { metadata } = session
- if (!metadata?.workspaceId || !metadata?.plan)
- return res.status(500).send({ message: `customer_email not found` })
+ const { workspaceId, plan, additionalChats, additionalStorage } =
+ session.metadata as unknown as {
+ plan: 'STARTER' | 'PRO'
+ additionalChats: string
+ additionalStorage: string
+ workspaceId: string
+ }
+
+ if (!workspaceId || !plan || !additionalChats || !additionalStorage)
+ return res
+ .status(500)
+ .send({ message: `Couldn't retrieve valid metadata` })
await prisma.workspace.update({
- where: { id: metadata.workspaceId },
+ where: { id: workspaceId },
data: {
- plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO,
+ plan: plan,
stripeId: session.customer as string,
+ additionalChatsIndex: parseInt(additionalChats),
+ additionalStorageIndex: parseInt(additionalStorage),
},
})
return res.status(200).send({ message: 'workspace upgraded in DB' })
}
- case 'customer.subscription.deleted': {
- const subscription = event.data.object as Stripe.Subscription
- await prisma.workspace.update({
- where: {
- stripeId: subscription.customer as string,
- },
- data: {
- plan: Plan.FREE,
- },
- })
- return res.send({ message: 'workspace downgraded in DB' })
- }
default: {
return res.status(304).send({ message: 'event not handled' })
}
diff --git a/apps/builder/pages/api/typebots/[typebotId]/invitations.ts b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts
index 044fa638ab6..b01fdb67119 100644
--- a/apps/builder/pages/api/typebots/[typebotId]/invitations.ts
+++ b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts
@@ -4,7 +4,7 @@ import { CollaborationType, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
-import { sendEmailNotification } from 'services/api/emails'
+import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
import {
badRequest,
@@ -29,6 +29,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const typebot = await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
+ include: { workspace: { select: { name: true } } },
})
if (!typebot || !typebot.workspaceId) return forbidden(res)
const { email, type } =
@@ -70,10 +71,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await sendEmailNotification({
to: email,
subject: "You've been invited to collaborate 🤝",
- content: invitationToCollaborate(
- user.email ?? '',
- `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`
- ),
+ html: invitationToCollaborate({
+ hostEmail: user.email ?? '',
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`,
+ guestEmail: email.toLowerCase(),
+ typebotName: typebot.name,
+ workspaceName: typebot.workspace?.name ?? '',
+ }),
})
return res.send({
message: 'success',
diff --git a/apps/builder/pages/api/workspaces.ts b/apps/builder/pages/api/workspaces.ts
index 7510addc000..a651cf49661 100644
--- a/apps/builder/pages/api/workspaces.ts
+++ b/apps/builder/pages/api/workspaces.ts
@@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
-import { Workspace } from 'db'
+import { Plan, Workspace } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
@@ -22,7 +22,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
data: {
...data,
members: { create: [{ role: 'ADMIN', userId: user.id }] },
- plan: process.env.ADMIN_EMAIL === user.email ? 'TEAM' : 'FREE',
+ plan:
+ process.env.ADMIN_EMAIL === user.email ? Plan.LIFETIME : Plan.FREE,
},
})
return res.status(200).json({
diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
index b44829401fb..78c61f29280 100644
--- a/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
+++ b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
@@ -1,9 +1,17 @@
import { withSentry } from '@sentry/nextjs'
-import { WorkspaceInvitation, WorkspaceRole } from 'db'
+import { workspaceMemberInvitationEmail } from 'assets/emails/workspaceMemberInvitation'
+import { Workspace, WorkspaceInvitation, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
+import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
-import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
+import {
+ env,
+ forbidden,
+ methodNotAllowed,
+ notAuthenticated,
+ seatsLimit,
+} from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
@@ -20,6 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
},
})
if (!workspace) return forbidden(res)
+
+ if (await checkIfSeatsLimitReached(workspace))
+ return res.status(400).send('Seats limit reached')
if (existingUser) {
await prisma.memberInWorkspace.create({
data: {
@@ -37,11 +48,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
workspaceId: data.workspaceId,
},
})
- }
- const invitation = await prisma.workspaceInvitation.create({ data })
- return res.send({ invitation })
+ } else await prisma.workspaceInvitation.create({ data })
+ if (env('E2E_TEST') !== 'true')
+ await sendEmailNotification({
+ to: data.email,
+ subject: "You've been invited to collaborate 🤝",
+ html: workspaceMemberInvitationEmail({
+ workspaceName: workspace.name,
+ guestEmail: data.email,
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`,
+ hostEmail: user.email ?? '',
+ }),
+ })
+ return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
+const checkIfSeatsLimitReached = async (workspace: Workspace) => {
+ const existingMembersCount = await prisma.memberInWorkspace.count({
+ where: { workspaceId: workspace.id },
+ })
+ return existingMembersCount >= seatsLimit[workspace.plan].totalIncluded
+}
+
export default withSentry(handler)
diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/usage.ts b/apps/builder/pages/api/workspaces/[workspaceId]/usage.ts
new file mode 100644
index 00000000000..4aa59cef216
--- /dev/null
+++ b/apps/builder/pages/api/workspaces/[workspaceId]/usage.ts
@@ -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)
diff --git a/apps/builder/pages/typebots.tsx b/apps/builder/pages/typebots.tsx
index f16d5d2a092..41e3b0d990f 100644
--- a/apps/builder/pages/typebots.tsx
+++ b/apps/builder/pages/typebots.tsx
@@ -5,10 +5,11 @@ import { FolderContent } from 'components/dashboard/FolderContent'
import { TypebotDndContext } from 'contexts/TypebotDndContext'
import { useRouter } from 'next/router'
import { Spinner, Stack, Text, VStack } from '@chakra-ui/react'
-import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext'
import { NextPageContext } from 'next/types'
import { useWorkspace } from 'contexts/WorkspaceContext'
+import { Plan } from 'db'
+import { pay } from 'components/shared/ChangePlanForm/queries/updatePlan'
const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false)
@@ -17,16 +18,15 @@ const DashboardPage = () => {
const { workspace } = useWorkspace()
useEffect(() => {
- const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
+ const subscribePlan = query.subscribePlan as 'pro' | 'starter' | undefined
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
pay({
user,
- plan: subscribePlan,
+ plan: subscribePlan === 'pro' ? Plan.PRO : Plan.STARTER,
workspaceId: workspace.id,
- currency: navigator.languages.find((l) => l.includes('fr'))
- ? 'eur'
- : 'usd',
+ additionalChats: 0,
+ additionalStorage: 0,
})
}
}, [query, user, workspace])
diff --git a/apps/builder/pages/typebots/[typebotId]/results.tsx b/apps/builder/pages/typebots/[typebotId]/results.tsx
index 274c9fd277d..4c9773a1679 100644
--- a/apps/builder/pages/typebots/[typebotId]/results.tsx
+++ b/apps/builder/pages/typebots/[typebotId]/results.tsx
@@ -11,6 +11,12 @@ import { useRouter } from 'next/router'
import { useStats } from 'services/analytics'
import { useToast } from 'components/shared/hooks/useToast'
import { ResultsProvider } from 'contexts/ResultsProvider'
+import { UnlockPlanInfo } from 'components/shared/Info'
+import { getChatsLimit, getStorageLimit } from 'utils'
+import { useUsage } from 'components/dashboard/WorkspaceSettingsModal/BillingContent/UsageContent/useUsage'
+
+const ALERT_CHATS_PERCENT_THRESHOLD = 80
+const ALERT_STORAGE_PERCENT_THRESHOLD = 80
const ResultsPage = () => {
const router = useRouter()
@@ -26,6 +32,45 @@ const ResultsPage = () => {
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
+ const { data: usageData } = useUsage(workspace?.id)
+
+ console.log(workspace?.id, usageData)
+
+ const chatsLimitPercentage = useMemo(() => {
+ if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
+ return Math.round(
+ (usageData.totalChatsUsed /
+ getChatsLimit({
+ additionalChatsIndex: workspace.additionalChatsIndex,
+ plan: workspace.plan,
+ })) *
+ 100
+ )
+ }, [
+ usageData?.totalChatsUsed,
+ workspace?.additionalChatsIndex,
+ workspace?.plan,
+ ])
+
+ const storageLimitPercentage = useMemo(() => {
+ console.log(usageData?.totalStorageUsed)
+ if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
+ return Math.round(
+ (usageData.totalStorageUsed /
+ 1024 /
+ 1024 /
+ 1024 /
+ getStorageLimit({
+ additionalStorageIndex: workspace.additionalStorageIndex,
+ plan: workspace.plan,
+ })) *
+ 100
+ )
+ }, [
+ usageData?.totalStorageUsed,
+ workspace?.additionalStorageIndex,
+ workspace?.plan,
+ ])
const handleDeletedResults = (total: number) => {
if (!stats) return
@@ -40,6 +85,38 @@ const ResultsPage = () => {
title={router.pathname.endsWith('analytics') ? 'Analytics' : 'Results'}
/>
+ {chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
+
+
+ Your workspace collected{' '}
+ {chatsLimitPercentage}% of your total chats
+ limit this month. Upgrade your plan to continue chatting with
+ your customers beyond this limit.
+ >
+ }
+ buttonLabel="Upgrade"
+ />
+
+ )}
+ {storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
+
+
+ Your workspace collected{' '}
+ {storageLimitPercentage}% 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"
+ />
+
+ )}
{
document.dispatchEvent(event)
}
-export const mockSessionApiCalls = (page: Page) =>
+export const connectedAsOtherUser = async (page: Page) =>
page.route('/api/auth/session', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
- body: '{"user":{"id":"proUser","name":"Pro user","email":"pro-user@email.com","emailVerified":null,"image":"https://avatars.githubusercontent.com/u/16015833?v=4","stripeId":null,"graphNavigation": "TRACKPAD"}}',
+ body: '{"user":{"id":"otherUserId","name":"James Doe","email":"other-user@email.com","emailVerified":null,"image":"https://avatars.githubusercontent.com/u/16015833?v=4","stripeId":null,"graphNavigation": "TRACKPAD"}}',
})
}
return route.continue()
diff --git a/apps/builder/playwright/services/database.ts b/apps/builder/playwright/services/database.ts
index 428d9157bc2..d41fe4fb92c 100644
--- a/apps/builder/playwright/services/database.ts
+++ b/apps/builder/playwright/services/database.ts
@@ -15,52 +15,129 @@ import {
PrismaClient,
User,
WorkspaceRole,
+ Workspace,
} from 'db'
import { readFileSync } from 'fs'
-import { encrypt } from 'utils'
+import { encrypt, createFakeResults } from 'utils'
+import Stripe from 'stripe'
const prisma = new PrismaClient()
-const proWorkspaceId = 'proWorkspace'
+const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY ?? '', {
+ apiVersion: '2022-08-01',
+})
+
+const userId = 'userId'
+const otherUserId = 'otherUserId'
export const freeWorkspaceId = 'freeWorkspace'
-export const sharedWorkspaceId = 'sharedWorkspace'
-export const guestWorkspaceId = 'guestWorkspace'
+export const starterWorkspaceId = 'starterWorkspace'
+export const proWorkspaceId = 'proWorkspace'
+const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
export const teardownDatabase = async () => {
- const ownerFilter = {
- where: {
- workspace: {
- members: { some: { userId: { in: ['freeUser', 'proUser'] } } },
- },
- },
- }
await prisma.workspace.deleteMany({
where: {
members: {
- some: { userId: { in: ['freeUser', 'proUser'] } },
+ some: { userId: { in: [userId, otherUserId] } },
},
},
})
await prisma.user.deleteMany({
- where: { id: { in: ['freeUser', 'proUser'] } },
+ where: { id: { in: [userId, otherUserId] } },
+ })
+ return prisma.webhook.deleteMany()
+}
+
+export const addSubscriptionToWorkspace = async (
+ workspaceId: string,
+ items: Stripe.SubscriptionCreateParams.Item[],
+ metadata: Pick<
+ Workspace,
+ 'additionalChatsIndex' | 'additionalStorageIndex' | 'plan'
+ >
+) => {
+ const { id: stripeId } = await stripe.customers.create({
+ email: 'test-user@gmail.com',
+ name: 'Test User',
+ })
+ const { id: paymentId } = await stripe.paymentMethods.create({
+ card: {
+ number: '4242424242424242',
+ exp_month: 12,
+ exp_year: 2022,
+ cvc: '123',
+ },
+ type: 'card',
+ })
+ await stripe.paymentMethods.attach(paymentId, { customer: stripeId })
+ await stripe.subscriptions.create({
+ customer: stripeId,
+ items,
+ default_payment_method: paymentId,
+ currency: 'usd',
+ })
+ await prisma.workspace.update({
+ where: { id: workspaceId },
+ data: {
+ stripeId,
+ ...metadata,
+ },
})
- await prisma.webhook.deleteMany()
- await prisma.credentials.deleteMany(ownerFilter)
- await prisma.dashboardFolder.deleteMany(ownerFilter)
- return prisma.typebot.deleteMany(ownerFilter)
}
export const setupDatabase = async () => {
+ await createWorkspaces()
await createUsers()
return createCredentials()
}
+export const createWorkspaces = async () =>
+ prisma.workspace.createMany({
+ data: [
+ {
+ id: freeWorkspaceId,
+ name: 'Free workspace',
+ plan: Plan.FREE,
+ },
+ {
+ id: starterWorkspaceId,
+ name: 'Starter workspace',
+ stripeId: 'cus_LnPDugJfa18N41',
+ plan: Plan.STARTER,
+ },
+ {
+ id: proWorkspaceId,
+ name: 'Pro workspace',
+ plan: Plan.PRO,
+ },
+ {
+ id: lifetimeWorkspaceId,
+ name: 'Lifetime workspace',
+ plan: Plan.LIFETIME,
+ },
+ ],
+ })
+
+export const createWorkspace = async (workspace: Partial) => {
+ const { id: workspaceId } = await prisma.workspace.create({
+ data: {
+ name: 'Free workspace',
+ plan: Plan.FREE,
+ ...workspace,
+ },
+ })
+ await prisma.memberInWorkspace.create({
+ data: { userId, workspaceId, role: WorkspaceRole.ADMIN },
+ })
+ return workspaceId
+}
+
export const createUsers = async () => {
await prisma.user.create({
data: {
- id: 'proUser',
- email: 'pro-user@email.com',
- name: 'Pro user',
+ id: userId,
+ email: 'user@email.com',
+ name: 'John Doe',
graphNavigation: GraphNavigation.TRACKPAD,
apiTokens: {
createMany: {
@@ -83,69 +160,34 @@ export const createUsers = async () => {
],
},
},
- workspaces: {
- create: {
- role: WorkspaceRole.ADMIN,
- workspace: {
- create: {
- id: proWorkspaceId,
- name: "Pro user's workspace",
- plan: Plan.TEAM,
- },
- },
- },
- },
},
})
await prisma.user.create({
- data: {
- id: 'freeUser',
- email: 'free-user@email.com',
- name: 'Free user',
- graphNavigation: GraphNavigation.TRACKPAD,
- workspaces: {
- create: {
- role: WorkspaceRole.ADMIN,
- workspace: {
- create: {
- id: 'free',
- name: "Free user's workspace",
- plan: Plan.FREE,
- },
- },
- },
- },
- },
+ data: { id: otherUserId, email: 'other-user@email.com', name: 'James Doe' },
})
- await prisma.workspace.create({
- data: {
- id: freeWorkspaceId,
- name: 'Free Shared workspace',
- plan: Plan.FREE,
- members: {
- createMany: {
- data: [
- { role: WorkspaceRole.MEMBER, userId: 'proUser' },
- { role: WorkspaceRole.ADMIN, userId: 'freeUser' },
- ],
- },
+ return prisma.memberInWorkspace.createMany({
+ data: [
+ {
+ role: WorkspaceRole.ADMIN,
+ userId,
+ workspaceId: freeWorkspaceId,
},
- },
- })
- return prisma.workspace.create({
- data: {
- id: sharedWorkspaceId,
- name: 'Shared workspace',
- plan: Plan.TEAM,
- members: {
- createMany: {
- data: [
- { role: WorkspaceRole.MEMBER, userId: 'proUser' },
- { role: WorkspaceRole.ADMIN, userId: 'freeUser' },
- ],
- },
+ {
+ role: WorkspaceRole.ADMIN,
+ userId,
+ workspaceId: starterWorkspaceId,
},
- },
+ {
+ role: WorkspaceRole.ADMIN,
+ userId,
+ workspaceId: proWorkspaceId,
+ },
+ {
+ role: WorkspaceRole.ADMIN,
+ userId,
+ workspaceId: lifetimeWorkspaceId,
+ },
+ ],
})
}
@@ -173,12 +215,12 @@ export const getSignedInUser = (email: string) =>
export const createTypebots = async (partialTypebots: Partial[]) => {
await prisma.typebot.createMany({
- data: partialTypebots.map(parseTestTypebot) as any[],
+ data: partialTypebots.map(parseTestTypebot),
})
return prisma.publicTypebot.createMany({
data: partialTypebots.map((t) =>
parseTypebotToPublicTypebot(t.id + '-public', parseTestTypebot(t))
- ) as any[],
+ ),
})
}
@@ -217,43 +259,11 @@ export const updateUser = (data: Partial) =>
prisma.user.update({
data,
where: {
- id: 'proUser',
+ id: userId,
},
})
-export const createResults = async ({ typebotId }: { typebotId: string }) => {
- await prisma.result.deleteMany()
- await prisma.result.createMany({
- data: [
- ...Array.from(Array(200)).map((_, idx) => {
- const today = new Date()
- const rand = Math.random()
- return {
- id: `result${idx}`,
- typebotId,
- createdAt: new Date(
- today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
- ),
- isCompleted: rand > 0.5,
- }
- }),
- ],
- })
- return createAnswers()
-}
-
-const createAnswers = () => {
- return prisma.answer.createMany({
- data: [
- ...Array.from(Array(200)).map((_, idx) => ({
- resultId: `result${idx}`,
- content: `content${idx}`,
- blockId: 'block1',
- groupId: 'block1',
- })),
- ],
- })
-}
+export const createResults = createFakeResults(prisma)
export const createFolder = (workspaceId: string, name: string) =>
prisma.dashboardFolder.create({
@@ -352,6 +362,6 @@ export const importTypebotInDatabase = async (
data: parseTypebotToPublicTypebot(
updates?.id ? `${updates?.id}-public` : 'publicBot',
typebot
- ) as any,
+ ),
})
}
diff --git a/apps/builder/playwright/tests/accountSettings.spec.ts b/apps/builder/playwright/tests/accountSettings.spec.ts
index 152536acedd..1e8a558ffeb 100644
--- a/apps/builder/playwright/tests/accountSettings.spec.ts
+++ b/apps/builder/playwright/tests/accountSettings.spec.ts
@@ -1,10 +1,6 @@
import test, { expect } from '@playwright/test'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
-// Can't test the update features because of the auth mocking.
test('should display user info properly', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
diff --git a/apps/builder/playwright/tests/billing.spec.ts b/apps/builder/playwright/tests/billing.spec.ts
new file mode 100644
index 00000000000..f4b9ef2e779
--- /dev/null
+++ b/apps/builder/playwright/tests/billing.spec.ts
@@ -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()
+})
diff --git a/apps/builder/playwright/tests/bubbles/embed.spec.ts b/apps/builder/playwright/tests/bubbles/embed.spec.ts
index f6028c7f7cf..3d63d0c67ed 100644
--- a/apps/builder/playwright/tests/bubbles/embed.spec.ts
+++ b/apps/builder/playwright/tests/bubbles/embed.spec.ts
@@ -6,13 +6,10 @@ import {
import { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
const siteSrc = 'https://app.cal.com/baptistearno/15min'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Embed bubble block', () => {
test.describe('Content settings', () => {
test('should import and parse embed correctly', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/bubbles/image.spec.ts b/apps/builder/playwright/tests/bubbles/image.spec.ts
index de1b3b851a5..7f13a4881fc 100644
--- a/apps/builder/playwright/tests/bubbles/image.spec.ts
+++ b/apps/builder/playwright/tests/bubbles/image.spec.ts
@@ -7,13 +7,10 @@ import { BubbleBlockType, defaultImageBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import path from 'path'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const unsplashImageSrc =
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Image bubble block', () => {
test.describe('Content settings', () => {
test('should upload image file correctly', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/bubbles/text.spec.ts b/apps/builder/playwright/tests/bubbles/text.spec.ts
index 4353b63c6e3..85f84e7d73e 100644
--- a/apps/builder/playwright/tests/bubbles/text.spec.ts
+++ b/apps/builder/playwright/tests/bubbles/text.spec.ts
@@ -6,9 +6,6 @@ import {
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Text bubble block', () => {
test('rich text features should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/bubbles/video.spec.ts b/apps/builder/playwright/tests/bubbles/video.spec.ts
index c1ca882c08f..4c8fffd8838 100644
--- a/apps/builder/playwright/tests/bubbles/video.spec.ts
+++ b/apps/builder/playwright/tests/bubbles/video.spec.ts
@@ -10,15 +10,12 @@ import {
} from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const videoSrc =
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const vimeoVideoSrc = 'https://vimeo.com/649301125'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Video bubble block', () => {
test.describe('Content settings', () => {
test('should import video url correctly', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/collaboration.spec.ts b/apps/builder/playwright/tests/collaboration.spec.ts
index de06701130a..38c6b189353 100644
--- a/apps/builder/playwright/tests/collaboration.spec.ts
+++ b/apps/builder/playwright/tests/collaboration.spec.ts
@@ -3,7 +3,6 @@ import cuid from 'cuid'
import { CollaborationType, Plan, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { InputBlockType, defaultTextInputOptions } from 'models'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import {
createFolder,
createResults,
@@ -11,8 +10,6 @@ import {
parseDefaultGroupWithBlock,
} from '../services/database'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Typebot owner', () => {
test('Can invite collaborators', async ({ page }) => {
const typebotId = cuid()
@@ -101,7 +98,7 @@ test.describe('Collaborator', () => {
},
})
await createFolder(guestWorkspaceId, 'Guest folder')
- await createResults({ typebotId })
+ await createResults({ typebotId, count: 10 })
await page.goto(`/typebots`)
await page.click("text=Pro user's workspace")
await page.click('text=Guest workspace #2')
diff --git a/apps/builder/playwright/tests/customDomains.spec.ts b/apps/builder/playwright/tests/customDomains.spec.ts
index a3b5cae56d4..4fcede86867 100644
--- a/apps/builder/playwright/tests/customDomains.spec.ts
+++ b/apps/builder/playwright/tests/customDomains.spec.ts
@@ -2,14 +2,10 @@ import test, { expect } from '@playwright/test'
import { InputBlockType, defaultTextInputOptions } from 'models'
import {
createTypebots,
- freeWorkspaceId,
parseDefaultGroupWithBlock,
+ starterWorkspaceId,
} from '../services/database'
-import path from 'path'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test('should be able to connect custom domain', async ({ page }) => {
const typebotId = cuid()
@@ -47,16 +43,13 @@ test('should be able to connect custom domain', async ({ page }) => {
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
})
-test.describe('Free workspace', () => {
- test.use({
- storageState: path.join(__dirname, '../freeUser.json'),
- })
+test.describe('Starter workspace', () => {
test("Add my domain shouldn't be available", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
- workspaceId: freeWorkspaceId,
+ workspaceId: starterWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
diff --git a/apps/builder/playwright/tests/dashboard.spec.ts b/apps/builder/playwright/tests/dashboard.spec.ts
index d0ca5180665..a8f23aa5157 100644
--- a/apps/builder/playwright/tests/dashboard.spec.ts
+++ b/apps/builder/playwright/tests/dashboard.spec.ts
@@ -1,12 +1,9 @@
import test, { expect, Page } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import { createFolders, createTypebots } from '../services/database'
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Dashboard page', () => {
test('folders navigation should work', async ({ page }) => {
await page.goto('/typebots')
@@ -79,7 +76,7 @@ test.describe('Dashboard page', () => {
test.describe('Free user', () => {
test.use({
- storageState: path.join(__dirname, '../freeUser.json'),
+ storageState: path.join(__dirname, '../secondUser.json'),
})
test("create folder shouldn't be available", async ({ page }) => {
await page.goto('/typebots')
diff --git a/apps/builder/playwright/tests/editor.spec.ts b/apps/builder/playwright/tests/editor.spec.ts
index 6853a9549d8..fa814d4c66a 100644
--- a/apps/builder/playwright/tests/editor.spec.ts
+++ b/apps/builder/playwright/tests/editor.spec.ts
@@ -8,9 +8,6 @@ import { defaultTextInputOptions, InputBlockType } from 'models'
import path from 'path'
import cuid from 'cuid'
import { typebotViewer } from '../services/selectorUtils'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Editor', () => {
test('Edges connection should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/buttons.spec.ts b/apps/builder/playwright/tests/inputs/buttons.spec.ts
index 34ae4f97231..f93820c255a 100644
--- a/apps/builder/playwright/tests/inputs/buttons.spec.ts
+++ b/apps/builder/playwright/tests/inputs/buttons.spec.ts
@@ -8,9 +8,6 @@ import { defaultChoiceInputOptions, InputBlockType, ItemType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Buttons input block', () => {
test('can edit button items', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/date.spec.ts b/apps/builder/playwright/tests/inputs/date.spec.ts
index c4e771856b5..94a2e0d6500 100644
--- a/apps/builder/playwright/tests/inputs/date.spec.ts
+++ b/apps/builder/playwright/tests/inputs/date.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultDateInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Date input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/email.spec.ts b/apps/builder/playwright/tests/inputs/email.spec.ts
index eb0207ea20e..1e2a7c2fb4a 100644
--- a/apps/builder/playwright/tests/inputs/email.spec.ts
+++ b/apps/builder/playwright/tests/inputs/email.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultEmailInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Email input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/file.spec.ts b/apps/builder/playwright/tests/inputs/file.spec.ts
index 40a3ecb467f..7db06f3436b 100644
--- a/apps/builder/playwright/tests/inputs/file.spec.ts
+++ b/apps/builder/playwright/tests/inputs/file.spec.ts
@@ -8,9 +8,6 @@ import { defaultFileInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.configure({ mode: 'parallel' })
@@ -61,9 +58,6 @@ test('options should work', async ({ page }) => {
})
test.describe('Free workspace', () => {
- test.use({
- storageState: path.join(__dirname, '../../freeUser.json'),
- })
test("shouldn't be able to publish typebot", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
diff --git a/apps/builder/playwright/tests/inputs/number.spec.ts b/apps/builder/playwright/tests/inputs/number.spec.ts
index eecb0e8cd03..b67a31d52b2 100644
--- a/apps/builder/playwright/tests/inputs/number.spec.ts
+++ b/apps/builder/playwright/tests/inputs/number.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultNumberInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Number input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/payment.spec.ts b/apps/builder/playwright/tests/inputs/payment.spec.ts
index db91e42c932..fbe8cab688c 100644
--- a/apps/builder/playwright/tests/inputs/payment.spec.ts
+++ b/apps/builder/playwright/tests/inputs/payment.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultPaymentInputOptions, InputBlockType } from 'models'
import cuid from 'cuid'
import { stripePaymentForm, typebotViewer } from '../../services/selectorUtils'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Payment input block', () => {
test('Can configure Stripe account', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/phone.spec.ts b/apps/builder/playwright/tests/inputs/phone.spec.ts
index 850a85350d8..3a49473d155 100644
--- a/apps/builder/playwright/tests/inputs/phone.spec.ts
+++ b/apps/builder/playwright/tests/inputs/phone.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultPhoneInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Phone input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/rating.spec.ts b/apps/builder/playwright/tests/inputs/rating.spec.ts
index 2c3d3e21c6b..0c6f50e52a1 100644
--- a/apps/builder/playwright/tests/inputs/rating.spec.ts
+++ b/apps/builder/playwright/tests/inputs/rating.spec.ts
@@ -6,7 +6,6 @@ import {
import { defaultRatingInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const boxSvg = `
`
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
diff --git a/apps/builder/playwright/tests/inputs/text.spec.ts b/apps/builder/playwright/tests/inputs/text.spec.ts
index 87f08c31691..bbad0e93234 100644
--- a/apps/builder/playwright/tests/inputs/text.spec.ts
+++ b/apps/builder/playwright/tests/inputs/text.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultTextInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Text input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/inputs/url.spec.ts b/apps/builder/playwright/tests/inputs/url.spec.ts
index 52283e56854..eacc289b7a8 100644
--- a/apps/builder/playwright/tests/inputs/url.spec.ts
+++ b/apps/builder/playwright/tests/inputs/url.spec.ts
@@ -6,9 +6,6 @@ import {
import { defaultUrlInputOptions, InputBlockType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Url input block', () => {
test('options should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/integrations/googleAnalytics.spec.ts b/apps/builder/playwright/tests/integrations/googleAnalytics.spec.ts
index 15983671810..a900d4a92a0 100644
--- a/apps/builder/playwright/tests/integrations/googleAnalytics.spec.ts
+++ b/apps/builder/playwright/tests/integrations/googleAnalytics.spec.ts
@@ -5,9 +5,6 @@ import {
} from '../../services/database'
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Google Analytics block', () => {
test('its configuration should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/integrations/googleSheets.spec.ts b/apps/builder/playwright/tests/integrations/googleSheets.spec.ts
index 39e0ce09e24..ceac43a51d7 100644
--- a/apps/builder/playwright/tests/integrations/googleSheets.spec.ts
+++ b/apps/builder/playwright/tests/integrations/googleSheets.spec.ts
@@ -3,9 +3,6 @@ import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe.parallel('Google sheets integration', () => {
test('Insert row should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
index c3b4f920305..af216c78648 100644
--- a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
+++ b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts
@@ -3,12 +3,9 @@ import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Send email block', () => {
test('its configuration should work', async ({ page }) => {
if (
diff --git a/apps/builder/playwright/tests/integrations/webhook.spec.ts b/apps/builder/playwright/tests/integrations/webhook.spec.ts
index 18292c081ce..8e3d4e8c42f 100644
--- a/apps/builder/playwright/tests/integrations/webhook.spec.ts
+++ b/apps/builder/playwright/tests/integrations/webhook.spec.ts
@@ -3,9 +3,6 @@ import { createWebhook, importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { HttpMethod } from 'models'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test.describe('Webhook block', () => {
test('easy configuration should work', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/logic/code.spec.ts b/apps/builder/playwright/tests/logic/code.spec.ts
index aed0e9b77b3..17a1f86f12c 100644
--- a/apps/builder/playwright/tests/logic/code.spec.ts
+++ b/apps/builder/playwright/tests/logic/code.spec.ts
@@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Code block', () => {
test('code should trigger', async ({ page }) => {
await importTypebotInDatabase(
diff --git a/apps/builder/playwright/tests/logic/condition.spec.ts b/apps/builder/playwright/tests/logic/condition.spec.ts
index 73bff91e38f..5f9c9effa24 100644
--- a/apps/builder/playwright/tests/logic/condition.spec.ts
+++ b/apps/builder/playwright/tests/logic/condition.spec.ts
@@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Condition block', () => {
test('its configuration should work', async ({ page }) => {
await importTypebotInDatabase(
diff --git a/apps/builder/playwright/tests/logic/redirect.spec.ts b/apps/builder/playwright/tests/logic/redirect.spec.ts
index fa43d5b7752..838288a5d2c 100644
--- a/apps/builder/playwright/tests/logic/redirect.spec.ts
+++ b/apps/builder/playwright/tests/logic/redirect.spec.ts
@@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Redirect block', () => {
test('its configuration should work', async ({ page, context }) => {
await importTypebotInDatabase(
diff --git a/apps/builder/playwright/tests/logic/setVariable.spec.ts b/apps/builder/playwright/tests/logic/setVariable.spec.ts
index 03a7151490e..b24141cd4bc 100644
--- a/apps/builder/playwright/tests/logic/setVariable.spec.ts
+++ b/apps/builder/playwright/tests/logic/setVariable.spec.ts
@@ -3,12 +3,9 @@ import path from 'path'
import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe('Set variable block', () => {
test('its configuration should work', async ({ page }) => {
await importTypebotInDatabase(
diff --git a/apps/builder/playwright/tests/logic/typebotLink.spec.ts b/apps/builder/playwright/tests/logic/typebotLink.spec.ts
index c70dcc568d2..19730ceb9a8 100644
--- a/apps/builder/playwright/tests/logic/typebotLink.spec.ts
+++ b/apps/builder/playwright/tests/logic/typebotLink.spec.ts
@@ -3,9 +3,6 @@ import { typebotViewer } from '../../services/selectorUtils'
import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import cuid from 'cuid'
-import { mockSessionApiCalls } from 'playwright/services/browser'
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
test('should be configurable', async ({ page }) => {
const typebotId = cuid()
diff --git a/apps/builder/playwright/tests/results.spec.ts b/apps/builder/playwright/tests/results.spec.ts
index 9f2951e3976..32a0ab9aedc 100644
--- a/apps/builder/playwright/tests/results.spec.ts
+++ b/apps/builder/playwright/tests/results.spec.ts
@@ -4,7 +4,6 @@ import { readFileSync } from 'fs'
import { defaultTextInputOptions, InputBlockType } from 'models'
import { parse } from 'papaparse'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import {
createResults,
createTypebots,
@@ -15,8 +14,6 @@ import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
const typebotId = cuid()
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test('Submission table header should be parsed correctly', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
@@ -46,7 +43,7 @@ test('results should be deletable', async ({ page }) => {
}),
},
])
- await createResults({ typebotId })
+ await createResults({ typebotId, count: 200 })
await page.goto(`/typebots/${typebotId}/results`)
await selectFirstResults(page)
await page.click('text="Delete"')
@@ -70,7 +67,7 @@ test('submissions table should have infinite scroll', async ({ page }) => {
tableWrapper.scrollTo(0, tableWrapper.scrollHeight)
})
- await createResults({ typebotId })
+ await createResults({ typebotId, count: 200 })
await page.goto(`/typebots/${typebotId}/results`)
await expect(page.locator('text=content199')).toBeVisible()
diff --git a/apps/builder/playwright/tests/settings.spec.ts b/apps/builder/playwright/tests/settings.spec.ts
index 0b14ca18445..c770792adb7 100644
--- a/apps/builder/playwright/tests/settings.spec.ts
+++ b/apps/builder/playwright/tests/settings.spec.ts
@@ -2,12 +2,9 @@ import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { defaultTextInputOptions } from 'models'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import { freeWorkspaceId, importTypebotInDatabase } from '../services/database'
import { typebotViewer } from '../services/selectorUtils'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Settings page', () => {
test.describe('General', () => {
test('should reflect change in real-time', async ({ page }) => {
@@ -123,10 +120,7 @@ test.describe.parallel('Settings page', () => {
})
})
- test.describe('Free user', () => {
- test.use({
- storageState: path.join(__dirname, '../freeUser.json'),
- })
+ test.describe('Free workspace', () => {
test("can't remove branding", async ({ page }) => {
const typebotId = 'free-branding-typebot'
await importTypebotInDatabase(
diff --git a/apps/builder/playwright/tests/templates.spec.ts b/apps/builder/playwright/tests/templates.spec.ts
index be84987404e..4e2608e336d 100644
--- a/apps/builder/playwright/tests/templates.spec.ts
+++ b/apps/builder/playwright/tests/templates.spec.ts
@@ -1,10 +1,7 @@
import test, { expect } from '@playwright/test'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import { typebotViewer } from '../services/selectorUtils'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Templates page', () => {
test('From scratch should create a blank typebot', async ({ page }) => {
await page.goto('/typebots/create')
diff --git a/apps/builder/playwright/tests/theme.spec.ts b/apps/builder/playwright/tests/theme.spec.ts
index 816a54cbfd0..186ef50d1d1 100644
--- a/apps/builder/playwright/tests/theme.spec.ts
+++ b/apps/builder/playwright/tests/theme.spec.ts
@@ -1,7 +1,6 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
-import { mockSessionApiCalls } from 'playwright/services/browser'
import { importTypebotInDatabase } from '../services/database'
import { typebotViewer } from '../services/selectorUtils'
@@ -10,8 +9,6 @@ const hostAvatarUrl =
const guestAvatarUrl =
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
-
test.describe.parallel('Theme page', () => {
test.describe('General', () => {
test('should reflect change in real-time', async ({ page }) => {
diff --git a/apps/builder/playwright/tests/workspaces.spec.ts b/apps/builder/playwright/tests/workspaces.spec.ts
index ad67a4f95ee..33551702d5b 100644
--- a/apps/builder/playwright/tests/workspaces.spec.ts
+++ b/apps/builder/playwright/tests/workspaces.spec.ts
@@ -1,25 +1,30 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { defaultTextInputOptions, InputBlockType } from 'models'
-import { mockSessionApiCalls } from 'playwright/services/browser'
+import { connectedAsOtherUser } from 'playwright/services/browser'
import {
createTypebots,
parseDefaultGroupWithBlock,
- sharedWorkspaceId,
+ proWorkspaceId,
+ starterWorkspaceId,
} from '../services/database'
const proTypebotId = cuid()
-const freeTypebotId = cuid()
-
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
+const starterTypebotId = cuid()
test.beforeAll(async () => {
- await createTypebots([{ id: proTypebotId, name: 'Pro typebot' }])
await createTypebots([
{
- id: freeTypebotId,
- name: 'Shared typebot',
- workspaceId: sharedWorkspaceId,
+ id: proTypebotId,
+ name: 'Pro typebot',
+ workspaceId: proWorkspaceId,
+ },
+ ])
+ await createTypebots([
+ {
+ id: starterTypebotId,
+ name: 'Starter typebot',
+ workspaceId: starterWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: {
@@ -37,38 +42,34 @@ test.beforeAll(async () => {
test('can switch between workspaces and access typebot', async ({ page }) => {
await page.goto('/typebots')
await expect(page.locator('text="Pro typebot"')).toBeVisible()
- await page.click("text=Pro user's workspace")
- await page.click('text="Shared workspace"')
+ await page.click('text=Pro workspace')
+ await page.click('text="Starter workspace"')
await expect(page.locator('text="Pro typebot"')).toBeHidden()
- await page.click('text="Shared typebot"')
+ await page.click('text="Starter typebot"')
await expect(page.locator('text="Hey there"')).toBeVisible()
})
test('can create and delete a new workspace', async ({ page }) => {
await page.goto('/typebots')
- await page.click("text=Pro user's workspace")
- await expect(
- page.locator('text="Pro user\'s workspace" >> nth=1')
- ).toBeHidden()
+ await page.click('text=Pro workspace')
+ await expect(page.locator('text="Pro workspace" >> nth=1')).toBeHidden()
await page.click('text=New workspace')
await expect(page.locator('text="Pro typebot"')).toBeHidden()
- await page.click("text=Pro user's workspace")
- await expect(
- page.locator('text="Pro user\'s workspace" >> nth=1')
- ).toBeVisible()
+ await page.click("text=John Doe's workspace")
+ await expect(page.locator('text="Pro workspace"')).toBeVisible()
await page.click('text=Settings & Members')
await page.click('text="Settings"')
await page.click('text="Delete workspace"')
await expect(
page.locator(
- "text=Are you sure you want to delete Pro user's workspace workspace?"
+ "text=Are you sure you want to delete John Doe's workspace workspace?"
)
).toBeVisible()
await page.click('text="Delete"')
- await expect(page.locator('text=Pro typebot')).toBeVisible()
- await page.click("text=Pro user's workspace")
+ await expect(page.locator('text=Free workspace')).toBeVisible()
+ await page.click('text=Free workspace')
await expect(
- page.locator('text="Pro user\'s workspace" >> nth=1')
+ page.locator('text="John Doe\'s workspace" >> nth=1')
).toBeHidden()
})
@@ -79,17 +80,14 @@ test('can update workspace info', async ({ page }) => {
await page.click('[data-testid="editable-icon"]')
await page.fill('input[placeholder="Search..."]', 'building')
await page.click('text="🏦"')
- await page.fill(
- 'input[value="Pro user\'s workspace"]',
- 'My awesome workspace'
- )
+ await page.fill('input[value="Pro workspace"]', 'My awesome workspace')
})
test('can manage members', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text="Members"')
- await expect(page.locator('text="pro-user@email.com"')).toBeVisible()
+ await expect(page.locator('text="user@email.com"')).toBeVisible()
await expect(page.locator('button >> text="Invite"')).toBeEnabled()
await page.fill(
'input[placeholder="colleague@company.com"]',
@@ -104,7 +102,7 @@ test('can manage members', async ({ page }) => {
await expect(page.locator('text="Pending"')).toBeVisible()
await page.fill(
'input[placeholder="colleague@company.com"]',
- 'free-user@email.com'
+ 'other-user@email.com'
)
await page.click('text="Member" >> nth=0')
await page.click('text="Admin"')
@@ -112,18 +110,15 @@ test('can manage members', async ({ page }) => {
await expect(
page.locator('input[placeholder="colleague@company.com"]')
).toHaveAttribute('value', '')
- await expect(page.locator('text="free-user@email.com"')).toBeVisible()
- await expect(page.locator('text="Free user"')).toBeVisible()
+ await expect(page.locator('text="other-user@email.com"')).toBeVisible()
+ await expect(page.locator('text="James Doe"')).toBeVisible()
- // Downgrade admin to member
- await page.click('text="free-user@email.com"')
+ await page.click('text="other-user@email.com"')
await page.click('button >> text="Member"')
await expect(page.locator('[data-testid="tag"] >> text="Admin"')).toHaveCount(
1
)
- await page.click('text="free-user@email.com"')
- await page.click('button >> text="Remove"')
- await expect(page.locator('text="free-user@email.com"')).toBeHidden()
+ await page.click('text="other-user@email.com"')
await page.click('text="guest@email.com"')
await page.click('text="Admin" >> nth=-1')
@@ -133,19 +128,46 @@ test('can manage members', async ({ page }) => {
await page.click('text="guest@email.com"')
await page.click('button >> text="Remove"')
await expect(page.locator('text="guest@email.com"')).toBeHidden()
-})
-test("can't edit workspace as a member", async ({ page }) => {
+ await connectedAsOtherUser(page)
await page.goto('/typebots')
- await page.click("text=Pro user's workspace")
- await page.click('text="Shared workspace"')
await page.click('text=Settings & Members')
await expect(page.locator('text="Settings"')).toBeHidden()
await page.click('text="Members"')
- await expect(page.locator('text="free-user@email.com"')).toBeVisible()
+ await expect(page.locator('text="other-user@email.com"')).toBeVisible()
await expect(
page.locator('input[placeholder="colleague@company.com"]')
).toBeHidden()
- await page.click('text="free-user@email.com"')
+ await page.click('text="other-user@email.com"')
await expect(page.locator('button >> text="Remove"')).toBeHidden()
})
+
+test("can't add new members when limit is reached", async ({ page }) => {
+ await page.goto('/typebots')
+ await page.click('text="Pro workspace"')
+ await page.click('text="Free workspace"')
+ await page.click('text=Settings & Members')
+ await page.click('text="Members"')
+ await expect(page.locator('button >> text="Invite"')).toBeDisabled()
+ await expect(
+ page.locator(
+ 'text="Upgrade your plan to work with more team members, and unlock awesome power features 🚀"'
+ )
+ ).toBeVisible()
+ await page.click('text="Free workspace"', { force: true })
+ await page.click('text="Free workspace"')
+ await page.click('text="Starter workspace"')
+ await page.click('text=Settings & Members')
+ await page.click('text="Members"')
+ await page.fill(
+ 'input[placeholder="colleague@company.com"]',
+ 'guest@email.com'
+ )
+ await page.click('button >> text="Invite"')
+ await expect(
+ page.locator(
+ 'text="Upgrade your plan to work with more team members, and unlock awesome power features 🚀"'
+ )
+ ).toBeVisible()
+ await expect(page.locator('button >> text="Invite"')).toBeDisabled()
+})
diff --git a/apps/builder/services/api/utils.ts b/apps/builder/services/api/utils.ts
index 46c4f697240..cdc326293ed 100644
--- a/apps/builder/services/api/utils.ts
+++ b/apps/builder/services/api/utils.ts
@@ -2,12 +2,11 @@ import { setUser } from '@sentry/nextjs'
import { User } from 'db'
import { NextApiRequest } from 'next'
import { getSession } from 'next-auth/react'
-import { env } from 'utils'
-const mockedUser: User = {
- id: 'proUser',
- name: 'Pro user',
- email: 'pro-user@email.com',
+export const mockedUser: User = {
+ id: 'userId',
+ name: 'John Doe',
+ email: 'user@email.com',
company: null,
createdAt: new Date(),
emailVerified: null,
@@ -22,7 +21,6 @@ export const getAuthenticatedUser = async (
req: NextApiRequest
): Promise => {
const session = await getSession({ req })
- if (env('E2E_TEST') === 'true' && !session?.user) return mockedUser
if (!session?.user || !('id' in session.user)) return
const user = session.user as User
setUser({ id: user.id, email: user.email ?? undefined })
diff --git a/apps/builder/services/utils/utils.ts b/apps/builder/services/utils/utils.ts
index fb071f7fbbe..b4e069ac799 100644
--- a/apps/builder/services/utils/utils.ts
+++ b/apps/builder/services/utils/utils.ts
@@ -119,3 +119,6 @@ export const timeSince = (date: string) => {
export const isCloudProdInstance = () =>
typeof window !== 'undefined' && window.location.hostname === 'app.typebot.io'
+
+export const numberWithCommas = (x: number) =>
+ x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
diff --git a/apps/builder/services/workspace/workspace.ts b/apps/builder/services/workspace/workspace.ts
index 6d896422734..bcc2f7a59ad 100644
--- a/apps/builder/services/workspace/workspace.ts
+++ b/apps/builder/services/workspace/workspace.ts
@@ -19,7 +19,19 @@ export const useWorkspaces = ({ userId }: { userId?: string }) => {
}
export const createNewWorkspace = async (
- body: Omit
+ body: Omit<
+ Workspace,
+ | 'id'
+ | 'icon'
+ | 'createdAt'
+ | 'stripeId'
+ | 'additionalChatsIndex'
+ | 'additionalStorageIndex'
+ | 'chatsLimitFirstEmailSentAt'
+ | 'chatsLimitSecondEmailSentAt'
+ | 'storageLimitFirstEmailSentAt'
+ | 'storageLimitSecondEmailSentAt'
+ >
) =>
sendRequest<{
workspace: Workspace
@@ -57,8 +69,6 @@ export const planToReadable = (plan?: Plan) => {
return 'Offered'
case Plan.PRO:
return 'Pro'
- case Plan.TEAM:
- return 'Team'
}
}
diff --git a/apps/landing-page/lib/chakraTheme.ts b/apps/landing-page/lib/chakraTheme.ts
index f97692bc576..2332330fd3f 100644
--- a/apps/landing-page/lib/chakraTheme.ts
+++ b/apps/landing-page/lib/chakraTheme.ts
@@ -80,6 +80,11 @@ const components = {
},
},
},
+ Tooltip: {
+ baseStyle: {
+ borderRadius: 'md',
+ },
+ },
Link: {
baseStyle: {
_hover: { textDecoration: 'none' },
diff --git a/apps/viewer/assets/emails/almostReachedChatsLimitEmail.mjml b/apps/viewer/assets/emails/almostReachedChatsLimitEmail.mjml
new file mode 100644
index 00000000000..4f10a19d53b
--- /dev/null
+++ b/apps/viewer/assets/emails/almostReachedChatsLimitEmail.mjml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ Your bots are chatting a lot. That's amazing. ❤️
+ This means you've almost reached your monthly chats limit. You currently reached 80% of ${readableChatsLimit}.
+ This limit will be reset on ${readableResetDate}.
+ Your bots won't start the chat if you reach the limit before this date. ⚠️
+ If you need more monthly responses, you will need to upgrade your plan.
+
+
+
+
+ Upgrade workspace
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/viewer/assets/emails/almostReachedChatsLimitEmail.ts b/apps/viewer/assets/emails/almostReachedChatsLimitEmail.ts
new file mode 100644
index 00000000000..f8de632679a
--- /dev/null
+++ b/apps/viewer/assets/emails/almostReachedChatsLimitEmail.ts
@@ -0,0 +1,33 @@
+type Props = {
+ readableChatsLimit: string
+ readableResetDate: string
+ url: string
+}
+
+export const almostReachedChatsLimitEmail = ({
+ url,
+ readableChatsLimit,
+ readableResetDate,
+}: Props) => `Your bots are chatting a lot. That's amazing. ❤️
This means you've almost reached your monthly chats limit. You currently reached 80% of ${readableChatsLimit}.
This limit will be reset on ${readableResetDate}.
Your bots won't start the chat if you reach the limit before this date. ⚠️
If you need more monthly responses, you will need to upgrade your plan.
`
diff --git a/apps/viewer/assets/emails/almostReachedStorageLimitEmail.mjml b/apps/viewer/assets/emails/almostReachedStorageLimitEmail.mjml
new file mode 100644
index 00000000000..d30db5fbb09
--- /dev/null
+++ b/apps/viewer/assets/emails/almostReachedStorageLimitEmail.mjml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ Your bots are working a lot. That's amazing. 🤖
+ This means you've almost reached your storage limit. You currently reached 80% of your ${readableStorageLimit} limit.
+ Your bots won't collect new files once you reach the limit. ⚠️
+ To make sure it won't happen, you need to upgrade your plan or delete existing results to free up space.
+
+
+
+
+ Upgrade workspace
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/viewer/assets/emails/almostReachedStorageLimitEmail.ts b/apps/viewer/assets/emails/almostReachedStorageLimitEmail.ts
new file mode 100644
index 00000000000..c3ea6ced1ac
--- /dev/null
+++ b/apps/viewer/assets/emails/almostReachedStorageLimitEmail.ts
@@ -0,0 +1,31 @@
+type Props = {
+ readableStorageLimit: string
+ url: string
+}
+
+export const almostReachedStorageLimitEmail = ({
+ url,
+ readableStorageLimit,
+}: Props) => `Your bots are working a lot. That's amazing. ü§ñ
This means you've almost reached your storage limit. You currently reached 80% of your ${readableStorageLimit} limit.
Your bots won't collect new files once you reach the limit. ⚠️
To make sure it won't happen, you need to upgrade your plan or delete existing results to free up space.
`
diff --git a/apps/viewer/assets/newLeadEmailContent.ts b/apps/viewer/assets/emails/newLeadEmailContent.ts
similarity index 100%
rename from apps/viewer/assets/newLeadEmailContent.ts
rename to apps/viewer/assets/emails/newLeadEmailContent.ts
diff --git a/apps/viewer/assets/emails/reachedChatsLimitEmail.mjml b/apps/viewer/assets/emails/reachedChatsLimitEmail.mjml
new file mode 100644
index 00000000000..a9002e43593
--- /dev/null
+++ b/apps/viewer/assets/emails/reachedChatsLimitEmail.mjml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ It just happened, you've reached your monthly ${readableChatsLimit} chats limit 😮
+ It means your bots are closed until ${readableResetDate}.
+ If you'd like to continue chatting with your users this month, then you need to upgrade your plan. 🚀
+
+
+
+
+ Upgrade workspace
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/viewer/assets/emails/reachedChatsLimitEmail.ts b/apps/viewer/assets/emails/reachedChatsLimitEmail.ts
new file mode 100644
index 00000000000..67049263f8a
--- /dev/null
+++ b/apps/viewer/assets/emails/reachedChatsLimitEmail.ts
@@ -0,0 +1,33 @@
+type Props = {
+ readableChatsLimit: string
+ readableResetDate: string
+ url: string
+}
+
+export const reachedSChatsLimitEmail = ({
+ url,
+ readableChatsLimit,
+ readableResetDate,
+}: Props) => `It just happened, you've reached your monthly ${readableChatsLimit} chats limit üòÆ
It means your bots are closed until ${readableResetDate}.
If you'd like to continue chatting with your users this month, then you need to upgrade your plan. üöÄ
`
diff --git a/apps/viewer/assets/emails/reachedStorageLimitEmail.mjml b/apps/viewer/assets/emails/reachedStorageLimitEmail.mjml
new file mode 100644
index 00000000000..1e18d42f638
--- /dev/null
+++ b/apps/viewer/assets/emails/reachedStorageLimitEmail.mjml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ It just happened, you've reached your ${readableStorageLimit} storage limit 😮
+ It means your bots won't collect new files from your users.
+ If you'd like to continue collecting files, then you need to upgrade your plan or remove existing results to free up space. 🚀
+
+
+
+
+ Upgrade workspace
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/viewer/assets/emails/reachedStorageLimitEmail.ts b/apps/viewer/assets/emails/reachedStorageLimitEmail.ts
new file mode 100644
index 00000000000..9b057b2e0cf
--- /dev/null
+++ b/apps/viewer/assets/emails/reachedStorageLimitEmail.ts
@@ -0,0 +1,31 @@
+type Props = {
+ readableStorageLimit: string
+ url: string
+}
+
+export const reachedStorageLimitEmail = ({
+ url,
+ readableStorageLimit,
+}: Props) => `It just happened, you've reached your ${readableStorageLimit} storage limit üòÆ
It means your bots won't collect new files from your users.
If you'd like to continue collecting files, then you need to upgrade your plan or remove existing results to free up space. üöÄ
`
diff --git a/apps/viewer/layouts/TypebotPage.tsx b/apps/viewer/layouts/TypebotPage.tsx
index 07d0c0b7bae..6f11b9c2c9b 100644
--- a/apps/viewer/layouts/TypebotPage.tsx
+++ b/apps/viewer/layouts/TypebotPage.tsx
@@ -65,12 +65,14 @@ export const TypebotPage = ({
const resultIdFromSession = getExistingResultFromSession()
if (resultIdFromSession) setResultId(resultIdFromSession)
else {
- const { error, data: result } = await createResult(typebot.typebotId)
+ const { error, data } = await createResult(typebot.typebotId)
if (error) return setError(error)
- if (result) {
- setResultId(result.id)
+ if (data?.hasReachedLimit)
+ return setError(new Error('This bot is now closed.'))
+ if (data?.result) {
+ setResultId(data.result.id)
if (typebot.settings.general.isNewResultOnRefreshEnabled !== true)
- setResultInSession(result.id)
+ setResultInSession(data.result.id)
}
}
}
diff --git a/apps/viewer/package.json b/apps/viewer/package.json
index c328957f692..89d76788622 100644
--- a/apps/viewer/package.json
+++ b/apps/viewer/package.json
@@ -41,11 +41,14 @@
"@types/sanitize-html": "2.6.2",
"@typescript-eslint/eslint-plugin": "5.36.2",
"@typescript-eslint/parser": "5.36.2",
+ "dotenv": "^16.0.1",
"eslint": "8.23.0",
"eslint-config-next": "12.3.0",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^4.6.0",
"google-auth-library": "^8.5.1",
+ "handlebars": "^4.7.7",
+ "mjml": "^4.13.0",
"models": "workspace:*",
"next-transpile-modules": "^9.0.0",
"papaparse": "^5.3.2",
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
index 3cfdfcae93a..c385a9154a4 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
@@ -1,8 +1,22 @@
import { withSentry } from '@sentry/nextjs'
+import { almostReachedStorageLimitEmail } from 'assets/emails/almostReachedStorageLimitEmail'
+import { reachedStorageLimitEmail } from 'assets/emails/reachedStorageLimitEmail'
+import { WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { InputBlockType, PublicTypebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
-import { badRequest, generatePresignedUrl, methodNotAllowed, byId } from 'utils'
+import {
+ badRequest,
+ generatePresignedUrl,
+ methodNotAllowed,
+ byId,
+ getStorageLimit,
+ sendEmailNotification,
+ isDefined,
+ env,
+} from 'utils'
+
+const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const handler = async (
req: NextApiRequest,
@@ -24,6 +38,7 @@ const handler = async (
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
if (!filePath) return badRequest(res, 'Missing filePath or fileType')
+ const hasReachedStorageLimit = await checkStorageLimit(typebotId)
const typebot = (await prisma.publicTypebot.findFirst({
where: { typebotId },
})) as unknown as PublicTypebot
@@ -42,9 +57,118 @@ const handler = async (
sizeLimit: sizeLimit * 1024 * 1024,
})
- return res.status(200).send({ presignedUrl })
+ return res.status(200).send({ presignedUrl, hasReachedStorageLimit })
}
return methodNotAllowed(res)
}
+const checkStorageLimit = async (typebotId: string) => {
+ const typebot = await prisma.typebot.findFirst({
+ where: { id: typebotId },
+ include: {
+ workspace: {
+ select: {
+ id: true,
+ additionalStorageIndex: true,
+ plan: true,
+ storageLimitFirstEmailSentAt: true,
+ storageLimitSecondEmailSentAt: true,
+ },
+ },
+ },
+ })
+ if (!typebot?.workspace) throw new Error('Workspace not found')
+ const { workspace } = typebot
+ const {
+ _sum: { storageUsed: totalStorageUsed },
+ } = await prisma.answer.aggregate({
+ where: {
+ storageUsed: { gt: 0 },
+ result: {
+ typebot: {
+ workspace: {
+ id: typebot?.workspaceId,
+ },
+ },
+ },
+ },
+ _sum: { storageUsed: true },
+ })
+ if (!totalStorageUsed) return false
+ const hasSentFirstEmail = workspace.storageLimitFirstEmailSentAt !== null
+ const hasSentSecondEmail = workspace.storageLimitSecondEmailSentAt !== null
+ const storageLimit = getStorageLimit(typebot.workspace)
+ if (
+ totalStorageUsed >= storageLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
+ !hasSentFirstEmail &&
+ env('E2E_TEST') !== 'true'
+ )
+ sendAlmostReachStorageLimitEmail({
+ workspaceId: workspace.id,
+ storageLimit,
+ })
+ if (
+ totalStorageUsed >= storageLimit &&
+ !hasSentSecondEmail &&
+ env('E2E_TEST') !== 'true'
+ )
+ sendReachStorageLimitEmail({
+ workspaceId: workspace.id,
+ storageLimit,
+ })
+ return (totalStorageUsed ?? 0) >= getStorageLimit(typebot?.workspace)
+}
+
+const sendAlmostReachStorageLimitEmail = async ({
+ workspaceId,
+ storageLimit,
+}: {
+ workspaceId: string
+ storageLimit: number
+}) => {
+ const members = await prisma.memberInWorkspace.findMany({
+ where: { role: WorkspaceRole.ADMIN, workspaceId },
+ include: { user: { select: { email: true } } },
+ })
+ const readableStorageLimit = `${storageLimit}GB`
+ await sendEmailNotification({
+ to: members.map((member) => member.user.email).filter(isDefined),
+ subject: "You're close to your storage limit",
+ html: almostReachedStorageLimitEmail({
+ readableStorageLimit,
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
+ }),
+ })
+ await prisma.workspace.update({
+ where: { id: workspaceId },
+ data: { storageLimitFirstEmailSentAt: new Date() },
+ })
+}
+
+const sendReachStorageLimitEmail = async ({
+ workspaceId,
+ storageLimit,
+}: {
+ workspaceId: string
+ storageLimit: number
+}) => {
+ const members = await prisma.memberInWorkspace.findMany({
+ where: { role: WorkspaceRole.ADMIN, workspaceId },
+ include: { user: { select: { email: true } } },
+ })
+ const readableStorageLimit = `${storageLimit}GB`
+ await sendEmailNotification({
+ to: members.map((member) => member.user.email).filter(isDefined),
+ subject: "You've hit your storage limit",
+ html: reachedStorageLimitEmail({
+ readableStorageLimit,
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
+ }),
+ })
+ await prisma.workspace.update({
+ where: { id: workspaceId },
+ data: { storageLimitSecondEmailSentAt: new Date() },
+ })
+}
+
export default withSentry(handler)
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/integrations/email.ts b/apps/viewer/pages/api/typebots/[typebotId]/integrations/email.ts
index c4c2c88e504..a7f29614db4 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/integrations/email.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/integrations/email.ts
@@ -25,7 +25,7 @@ import {
saveSuccessLog,
} from 'services/api/utils'
import Mail from 'nodemailer/lib/mailer'
-import { newLeadEmailContent } from 'assets/newLeadEmailContent'
+import { newLeadEmailContent } from 'assets/emails/newLeadEmailContent'
const cors = initMiddleware(Cors())
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results.ts b/apps/viewer/pages/api/typebots/[typebotId]/results.ts
index 26dedce5df0..f7f6ad23c28 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/results.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/results.ts
@@ -1,8 +1,20 @@
+import { almostReachedChatsLimitEmail } from 'assets/emails/almostReachedChatsLimitEmail'
+import { reachedSChatsLimitEmail } from 'assets/emails/reachedChatsLimitEmail'
+import { Workspace, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { ResultWithAnswers } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
-import { methodNotAllowed } from 'utils'
+import {
+ env,
+ getChatsLimit,
+ isDefined,
+ methodNotAllowed,
+ parseNumberWithCommas,
+ sendEmailNotification,
+} from 'utils'
+
+const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
@@ -30,10 +42,150 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
typebotId,
isCompleted: false,
},
+ include: {
+ typebot: {
+ include: {
+ workspace: {
+ select: {
+ id: true,
+ plan: true,
+ additionalChatsIndex: true,
+ chatsLimitFirstEmailSentAt: true,
+ chatsLimitSecondEmailSentAt: true,
+ },
+ },
+ },
+ },
+ },
})
- return res.send(result)
+ const hasReachedLimit = await checkChatsUsage(result.typebot.workspace)
+ res.send({ result, hasReachedLimit })
+ return
}
methodNotAllowed(res)
}
+const checkChatsUsage = async (
+ workspace: Pick<
+ Workspace,
+ | 'id'
+ | 'plan'
+ | 'additionalChatsIndex'
+ | 'chatsLimitFirstEmailSentAt'
+ | 'chatsLimitSecondEmailSentAt'
+ >
+) => {
+ const chatLimit = getChatsLimit(workspace)
+ if (chatLimit === -1) return
+ const now = new Date()
+ const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
+ const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
+ const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
+ const chatsCount = await prisma.result.count({
+ where: {
+ typebot: { workspaceId: workspace.id },
+ hasStarted: true,
+ createdAt: { gte: firstDayOfMonth, lte: lastDayOfMonth },
+ },
+ })
+ const hasSentFirstEmail =
+ workspace.chatsLimitFirstEmailSentAt !== null &&
+ workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
+ workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
+ const hasSentSecondEmail =
+ workspace.chatsLimitSecondEmailSentAt !== null &&
+ workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
+ workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
+ if (
+ chatsCount >= chatLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
+ !hasSentFirstEmail &&
+ env('E2E_TEST') !== 'true'
+ )
+ await sendAlmostReachChatsLimitEmail({
+ workspaceId: workspace.id,
+ chatLimit,
+ firstDayOfNextMonth,
+ })
+ if (
+ chatsCount >= chatLimit &&
+ !hasSentSecondEmail &&
+ env('E2E_TEST') !== 'true'
+ )
+ await sendReachedAlertEmail({
+ workspaceId: workspace.id,
+ chatLimit,
+ firstDayOfNextMonth,
+ })
+ return chatsCount >= chatLimit
+}
+
+const sendAlmostReachChatsLimitEmail = async ({
+ workspaceId,
+ chatLimit,
+ firstDayOfNextMonth,
+}: {
+ workspaceId: string
+ chatLimit: number
+ firstDayOfNextMonth: Date
+}) => {
+ const members = await prisma.memberInWorkspace.findMany({
+ where: { role: WorkspaceRole.ADMIN, workspaceId },
+ include: { user: { select: { email: true } } },
+ })
+ const readableChatsLimit = parseNumberWithCommas(chatLimit)
+ const readableResetDate = firstDayOfNextMonth
+ .toDateString()
+ .split(' ')
+ .slice(1, 4)
+ .join(' ')
+
+ await sendEmailNotification({
+ to: members.map((member) => member.user.email).filter(isDefined),
+ subject: "You've been invited to collaborate 🤝",
+ html: almostReachedChatsLimitEmail({
+ readableChatsLimit,
+ readableResetDate,
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
+ }),
+ })
+ await prisma.workspace.update({
+ where: { id: workspaceId },
+ data: { chatsLimitFirstEmailSentAt: new Date() },
+ })
+}
+
+const sendReachedAlertEmail = async ({
+ workspaceId,
+ chatLimit,
+ firstDayOfNextMonth,
+}: {
+ workspaceId: string
+ chatLimit: number
+ firstDayOfNextMonth: Date
+}) => {
+ const members = await prisma.memberInWorkspace.findMany({
+ where: { role: WorkspaceRole.ADMIN, workspaceId },
+ include: { user: { select: { email: true } } },
+ })
+ const readableChatsLimit = parseNumberWithCommas(chatLimit)
+ const readableResetDate = firstDayOfNextMonth
+ .toDateString()
+ .split(' ')
+ .slice(1, 4)
+ .join(' ')
+ await sendEmailNotification({
+ to: members.map((member) => member.user.email).filter(isDefined),
+ subject: "You've been invited to collaborate 🤝",
+ html: reachedSChatsLimitEmail({
+ readableChatsLimit,
+ readableResetDate,
+ url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
+ }),
+ })
+ await prisma.workspace.update({
+ where: { id: workspaceId },
+ data: { chatsLimitSecondEmailSentAt: new Date() },
+ })
+}
+
export default handler
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts b/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts
index e6f0647efce..f3e9f740830 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts
@@ -13,11 +13,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let storageUsed = 0
if (uploadedFiles && answer.content.includes('http')) {
const fileUrls = answer.content.split(', ')
- for (const url of fileUrls) {
- const { headers } = await got(url)
- const size = headers['content-length']
- if (isNotDefined(size)) return
- storageUsed += parseInt(size, 10)
+ const hasReachedStorageLimit = fileUrls[0] === null
+ if (!hasReachedStorageLimit) {
+ for (const url of fileUrls) {
+ const { headers } = await got(url)
+ const size = headers['content-length']
+ if (isNotDefined(size)) return
+ storageUsed += parseInt(size, 10)
+ }
}
}
const result = await prisma.answer.upsert({
diff --git a/apps/viewer/playwright/services/database.ts b/apps/viewer/playwright/services/database.ts
index c4a9a7af041..43180523bd4 100644
--- a/apps/viewer/playwright/services/database.ts
+++ b/apps/viewer/playwright/services/database.ts
@@ -8,51 +8,88 @@ import {
Typebot,
Webhook,
} from 'models'
-import { Plan, PrismaClient, WorkspaceRole } from 'db'
+import { GraphNavigation, Plan, PrismaClient, WorkspaceRole } from 'db'
import { readFileSync } from 'fs'
-import { encrypt } from 'utils'
+import { createFakeResults, encrypt } from 'utils'
const prisma = new PrismaClient()
-const proWorkspaceId = 'proWorkspaceViewer'
+const userId = 'userId'
+export const freeWorkspaceId = 'freeWorkspace'
+export const starterWorkspaceId = 'starterWorkspace'
export const teardownDatabase = async () => {
- try {
- await prisma.workspace.deleteMany({
- where: { members: { some: { userId: { in: ['proUser'] } } } },
- })
- await prisma.user.deleteMany({
- where: { id: { in: ['proUser'] } },
- })
- } catch (err) {
- console.error(err)
- }
- return
+ await prisma.workspace.deleteMany({
+ where: {
+ members: {
+ some: { userId },
+ },
+ },
+ })
+ await prisma.user.deleteMany({
+ where: { id: userId },
+ })
+ return prisma.webhook.deleteMany()
+}
+
+export const setupDatabase = async () => {
+ await createWorkspaces()
+ await createUser()
}
-export const setupDatabase = () => createUser()
+export const createWorkspaces = async () =>
+ prisma.workspace.createMany({
+ data: [
+ {
+ id: freeWorkspaceId,
+ name: 'Free workspace',
+ plan: Plan.FREE,
+ },
+ {
+ id: starterWorkspaceId,
+ name: 'Starter workspace',
+ plan: Plan.STARTER,
+ },
+ ],
+ })
-export const createUser = () =>
- prisma.user.create({
+export const createUser = async () => {
+ await prisma.user.create({
data: {
- id: 'proUser',
+ id: userId,
email: 'user@email.com',
- name: 'User',
- apiTokens: { create: { token: 'userToken', name: 'default' } },
- workspaces: {
- create: {
- role: WorkspaceRole.ADMIN,
- workspace: {
- create: {
- id: proWorkspaceId,
- name: 'Pro workspace',
- plan: Plan.PRO,
+ name: 'John Doe',
+ graphNavigation: GraphNavigation.TRACKPAD,
+ apiTokens: {
+ createMany: {
+ data: [
+ {
+ name: 'Token 1',
+ token: 'jirowjgrwGREHEtoken1',
+ createdAt: new Date(2022, 1, 1),
+ },
+ {
+ name: 'Github',
+ token: 'jirowjgrwGREHEgdrgithub',
+ createdAt: new Date(2022, 1, 2),
+ },
+ {
+ name: 'N8n',
+ token: 'jirowjgrwGREHrgwhrwn8n',
+ createdAt: new Date(2022, 1, 3),
},
- },
+ ],
},
},
},
})
+ await prisma.memberInWorkspace.createMany({
+ data: [
+ { role: WorkspaceRole.ADMIN, userId, workspaceId: freeWorkspaceId },
+ { role: WorkspaceRole.ADMIN, userId, workspaceId: starterWorkspaceId },
+ ],
+ })
+}
export const createWebhook = (typebotId: string, webhook?: Partial) =>
prisma.webhook.create({
@@ -66,12 +103,12 @@ export const createWebhook = (typebotId: string, webhook?: Partial) =>
export const createTypebots = async (partialTypebots: Partial[]) => {
await prisma.typebot.createMany({
- data: partialTypebots.map(parseTestTypebot) as any[],
+ data: partialTypebots.map(parseTestTypebot),
})
return prisma.publicTypebot.createMany({
data: partialTypebots.map((t) =>
parseTypebotToPublicTypebot(t.id + '-published', parseTestTypebot(t))
- ) as any[],
+ ),
})
}
@@ -107,7 +144,7 @@ const parseTestTypebot = (partialTypebot: Partial): Typebot => ({
id: partialTypebot.id ?? 'typebot',
folderId: null,
name: 'My typebot',
- workspaceId: proWorkspaceId,
+ workspaceId: freeWorkspaceId,
icon: null,
theme: defaultTheme,
settings: defaultSettings,
@@ -170,7 +207,7 @@ export const importTypebotInDatabase = async (
const typebot: Typebot = {
...JSON.parse(readFileSync(path).toString()),
...updates,
- workspaceId: proWorkspaceId,
+ workspaceId: starterWorkspaceId,
}
await prisma.typebot.create({
data: typebot,
@@ -183,39 +220,7 @@ export const importTypebotInDatabase = async (
})
}
-export const createResults = async ({ typebotId }: { typebotId: string }) => {
- await prisma.result.deleteMany()
- await prisma.result.createMany({
- data: [
- ...Array.from(Array(200)).map((_, idx) => {
- const today = new Date()
- const rand = Math.random()
- return {
- id: `result${idx}`,
- typebotId,
- createdAt: new Date(
- today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
- ),
- isCompleted: rand > 0.5,
- }
- }),
- ],
- })
- return createAnswers()
-}
-
-const createAnswers = () => {
- return prisma.answer.createMany({
- data: [
- ...Array.from(Array(200)).map((_, idx) => ({
- resultId: `result${idx}`,
- content: `content${idx}`,
- blockId: 'block1',
- groupId: 'group1',
- })),
- ],
- })
-}
+export const createResults = createFakeResults(prisma)
export const createSmtpCredentials = (
id: string,
@@ -229,7 +234,7 @@ export const createSmtpCredentials = (
iv,
name: smtpData.from.email as string,
type: CredentialsType.SMTP,
- workspaceId: proWorkspaceId,
+ workspaceId: freeWorkspaceId,
},
})
}
diff --git a/apps/viewer/playwright/tests/api.spec.ts b/apps/viewer/playwright/tests/api.spec.ts
index 965e1d01b3f..dd825e904d7 100644
--- a/apps/viewer/playwright/tests/api.spec.ts
+++ b/apps/viewer/playwright/tests/api.spec.ts
@@ -14,7 +14,7 @@ test.beforeAll(async () => {
{ id: typebotId }
)
await createWebhook(typebotId)
- await createResults({ typebotId })
+ await createResults({ typebotId, count: 20 })
} catch (err) {
console.log(err)
}
diff --git a/apps/viewer/playwright/tests/fileUpload.spec.ts b/apps/viewer/playwright/tests/fileUpload.spec.ts
index fd38d22bad5..11c5aa68420 100644
--- a/apps/viewer/playwright/tests/fileUpload.spec.ts
+++ b/apps/viewer/playwright/tests/fileUpload.spec.ts
@@ -3,12 +3,12 @@ import cuid from 'cuid'
import path from 'path'
import { parse } from 'papaparse'
import { typebotViewer } from '../services/selectorUtils'
-import { importTypebotInDatabase } from '../services/database'
+import { createResults, importTypebotInDatabase } from '../services/database'
import { readFileSync } from 'fs'
import { isDefined } from 'utils'
-import { mockSessionApiCalls } from 'playwright/services/browser'
+import { describe } from 'node:test'
-test.beforeEach(({ page }) => mockSessionApiCalls(page))
+const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
test('should work as expected', async ({ page, browser }) => {
const typebotId = cuid()
@@ -85,3 +85,46 @@ test('should work as expected', async ({ page, browser }) => {
page2.locator('span:has-text("The specified key does not exist.")')
).toBeVisible()
})
+
+describe('Storage limit is reached', () => {
+ const typebotId = cuid()
+
+ test.beforeAll(async () => {
+ await importTypebotInDatabase(
+ path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
+ {
+ id: typebotId,
+ publicId: `${typebotId}-public`,
+ }
+ )
+ await createResults({
+ typebotId,
+ count: 20,
+ fakeStorage: THREE_GIGABYTES,
+ })
+ })
+
+ test("shouldn't upload anything if limit has been reached", async ({
+ page,
+ }) => {
+ await page.goto(`/${typebotId}-public`)
+ await typebotViewer(page)
+ .locator(`input[type="file"]`)
+ .setInputFiles([
+ path.join(__dirname, '../fixtures/typebots/api.json'),
+ path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
+ path.join(__dirname, '../fixtures/typebots/hugeGroup.json'),
+ ])
+ await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
+ await typebotViewer(page).locator('text="Upload 3 files"').click()
+ await expect(
+ typebotViewer(page).locator(`text="3 files uploaded"`)
+ ).toBeVisible()
+ await page.evaluate(() =>
+ window.localStorage.setItem('workspaceId', 'starterWorkspace')
+ )
+ await page.goto(`${process.env.BUILDER_URL}/typebots/${typebotId}/results`)
+ await expect(page.locator('text="150%"')).toBeVisible()
+ await expect(page.locator('text="api.json"')).toBeHidden()
+ })
+})
diff --git a/apps/viewer/playwright/tests/limits.spec.ts b/apps/viewer/playwright/tests/limits.spec.ts
new file mode 100644
index 00000000000..6cb3623f2c8
--- /dev/null
+++ b/apps/viewer/playwright/tests/limits.spec.ts
@@ -0,0 +1,23 @@
+import test, { expect } from '@playwright/test'
+import {
+ createResults,
+ freeWorkspaceId,
+ importTypebotInDatabase,
+} from '../services/database'
+import cuid from 'cuid'
+import path from 'path'
+
+test('should not start if chat limit is reached', async ({ page }) => {
+ const typebotId = cuid()
+ await importTypebotInDatabase(
+ path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
+ {
+ id: typebotId,
+ publicId: `${typebotId}-public`,
+ workspaceId: freeWorkspaceId,
+ }
+ )
+ await createResults({ typebotId, count: 320 })
+ await page.goto(`/${typebotId}-public`)
+ await expect(page.locator('text="This bot is now closed."')).toBeVisible()
+})
diff --git a/apps/viewer/services/result.ts b/apps/viewer/services/result.ts
index 8e5bcb5de98..ee07d3d45c4 100644
--- a/apps/viewer/services/result.ts
+++ b/apps/viewer/services/result.ts
@@ -2,7 +2,7 @@ import { Result } from 'db'
import { sendRequest } from 'utils'
export const createResult = async (typebotId: string) => {
- return sendRequest({
+ return sendRequest<{ result: Result; hasReachedLimit: boolean }>({
url: `/api/typebots/${typebotId}/results`,
method: 'POST',
})
diff --git a/package.json b/package.json
index 24ab847819d..5d2aa457c84 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"docker:nuke": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans",
"dev:prepare": "turbo run build --scope=bot-engine --no-deps --include-dependencies && turbo run build --scope=typebot-js --no-deps",
"dev": "pnpm docker:up && pnpm dev:prepare && NEXT_PUBLIC_E2E_TEST=false turbo run dx --parallel",
- "dev:mocking": "pnpm docker:up && NEXT_PUBLIC_E2E_TEST=true turbo run dx --parallel",
+ "dev:mocking": "pnpm docker:up && pnpm dev:prepare && NEXT_PUBLIC_E2E_TEST=true turbo run dx --parallel",
"build": "pnpm docker:up && turbo run build",
"build:builder": "cp apps/builder/.env.docker apps/builder/.env.production && ENVSH_ENV=./apps/builder/.env.production ENVSH_OUTPUT=./apps/builder/public/__env.js bash env.sh pnpm turbo run build --scope=builder --include-dependencies",
"build:viewer": "cp apps/viewer/.env.docker apps/viewer/.env.production && ENVSH_ENV=./apps/viewer/.env.production ENVSH_OUTPUT=./apps/viewer/public/__env.js bash env.sh pnpm turbo run build --scope=viewer --include-dependencies",
diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx
index 7644f8f6893..f7652c9d5bc 100644
--- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx
+++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx
@@ -65,7 +65,8 @@ export const FileUploadForm = ({
],
})
setIsUploading(false)
- if (urls.length) return onSubmit({ label: `File uploaded`, value: urls[0] })
+ if (urls.length)
+ return onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
diff --git a/packages/db/prisma/migrations/20220918083055_add_usage_based_pricing/migration.sql b/packages/db/prisma/migrations/20220918083055_add_usage_based_pricing/migration.sql
new file mode 100644
index 00000000000..f183a9dd688
--- /dev/null
+++ b/packages/db/prisma/migrations/20220918083055_add_usage_based_pricing/migration.sql
@@ -0,0 +1,18 @@
+BEGIN;
+UPDATE "Workspace" SET "plan" = 'PRO' WHERE "plan" = 'TEAM';
+CREATE TYPE "Plan_new" AS ENUM ('FREE', 'STARTER', 'PRO', 'LIFETIME', 'OFFERED');
+ALTER TABLE "Workspace" ALTER COLUMN "plan" DROP DEFAULT;
+ALTER TABLE "Workspace" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
+ALTER TYPE "Plan" RENAME TO "Plan_old";
+ALTER TYPE "Plan_new" RENAME TO "Plan";
+DROP TYPE "Plan_old";
+ALTER TABLE "Workspace" ALTER COLUMN "plan" SET DEFAULT 'FREE';
+UPDATE "Workspace" SET "plan" = 'STARTER' WHERE "plan" = 'PRO';
+COMMIT;
+
+ALTER TABLE "Workspace" ADD COLUMN "additionalChatsIndex" INTEGER NOT NULL DEFAULT 0,
+ADD COLUMN "additionalStorageIndex" INTEGER NOT NULL DEFAULT 0,
+ADD COLUMN "chatsLimitFirstEmailSentAt" TIMESTAMP(3),
+ADD COLUMN "chatsLimitSecondEmailSentAt" TIMESTAMP(3),
+ADD COLUMN "storageLimitFirstEmailSentAt" TIMESTAMP(3),
+ADD COLUMN "storageLimitSecondEmailSentAt" TIMESTAMP(3);
\ No newline at end of file
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 38e76eaa4a5..65388aba965 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -78,6 +78,12 @@ model Workspace {
members MemberInWorkspace[]
typebots Typebot[]
invitations WorkspaceInvitation[]
+ additionalChatsIndex Int @default(0)
+ additionalStorageIndex Int @default(0)
+ chatsLimitFirstEmailSentAt DateTime?
+ storageLimitFirstEmailSentAt DateTime?
+ chatsLimitSecondEmailSentAt DateTime?
+ storageLimitSecondEmailSentAt DateTime?
}
model MemberInWorkspace {
@@ -267,8 +273,8 @@ enum GraphNavigation {
enum Plan {
FREE
+ STARTER
PRO
- TEAM
LIFETIME
OFFERED
}
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 88052820389..5fcee4532d4 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -14,18 +14,23 @@
"@rollup/plugin-commonjs": "22.0.2",
"@rollup/plugin-node-resolve": "^14.0.1",
"@rollup/plugin-typescript": "8.5.0",
+ "@types/nodemailer": "6.4.5",
+ "aws-sdk": "2.1213.0",
+ "db": "workspace:*",
+ "models": "workspace:*",
+ "next": "12.3.0",
+ "nodemailer": "^6.7.8",
"rollup": "2.79.0",
"rollup-plugin-dts": "^4.2.2",
"rollup-plugin-peer-deps-external": "^2.2.4",
"tslib": "^2.4.0",
- "typescript": "^4.8.3",
- "aws-sdk": "2.1213.0",
- "models": "workspace:*",
- "next": "12.3.0"
+ "typescript": "^4.8.3"
},
"peerDependencies": {
"aws-sdk": "^2.1152.0",
+ "db": "workspace:*",
"models": "workspace:*",
- "next": "^12.0.0"
+ "next": "^12.0.0",
+ "nodemailer": "^6.7.8"
}
}
diff --git a/packages/utils/src/api/index.ts b/packages/utils/src/api/index.ts
index 51e04bca0b2..d59e20a8040 100644
--- a/packages/utils/src/api/index.ts
+++ b/packages/utils/src/api/index.ts
@@ -1,2 +1,3 @@
export * from './utils'
export * from './storage'
+export * from './sendEmailNotification'
diff --git a/apps/builder/services/api/emails.ts b/packages/utils/src/api/sendEmailNotification.ts
similarity index 53%
rename from apps/builder/services/api/emails.ts
rename to packages/utils/src/api/sendEmailNotification.ts
index cb32e914244..71a2fe20942 100644
--- a/apps/builder/services/api/emails.ts
+++ b/packages/utils/src/api/sendEmailNotification.ts
@@ -1,15 +1,7 @@
-import { createTransport } from 'nodemailer'
-import { env } from 'utils'
+import { createTransport, SendMailOptions } from 'nodemailer'
+import { env } from '../utils'
-export const sendEmailNotification = ({
- to,
- subject,
- content,
-}: {
- to: string
- subject: string
- content: string
-}) => {
+export const sendEmailNotification = (props: Omit) => {
const transporter = createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
@@ -21,8 +13,6 @@ export const sendEmailNotification = ({
return transporter.sendMail({
from: env('SMTP_FROM'),
- to,
- subject,
- html: content,
+ ...props,
})
}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index bced8c0dfe7..06c7a0f303f 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -2,3 +2,5 @@ export * from './utils'
export * from './api'
export * from './encryption'
export * from './results'
+export * from './pricing'
+export * from './playwright'
diff --git a/packages/utils/src/playwright.ts b/packages/utils/src/playwright.ts
new file mode 100644
index 00000000000..7b3cd2a20ff
--- /dev/null
+++ b/packages/utils/src/playwright.ts
@@ -0,0 +1,60 @@
+import { PrismaClient } from 'db'
+
+type CreateFakeResultsProps = {
+ typebotId: string
+ count: number
+ idPrefix?: string
+ isChronological?: boolean
+ fakeStorage?: number
+}
+
+export const createFakeResults =
+ (prisma: PrismaClient) =>
+ async ({
+ count,
+ idPrefix = '',
+ typebotId,
+ isChronological = true,
+ fakeStorage,
+ }: CreateFakeResultsProps) => {
+ await prisma.result.createMany({
+ data: [
+ ...Array.from(Array(count)).map((_, idx) => {
+ const today = new Date()
+ const rand = Math.random()
+ return {
+ id: `${idPrefix}-result${idx}`,
+ typebotId,
+ createdAt: isChronological
+ ? new Date(
+ today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
+ )
+ : new Date(),
+ isCompleted: rand > 0.5,
+ hasStarted: true,
+ }
+ }),
+ ],
+ })
+ return createAnswers(prisma)({ idPrefix, fakeStorage, count })
+ }
+
+const createAnswers =
+ (prisma: PrismaClient) =>
+ ({
+ count,
+ idPrefix,
+ fakeStorage,
+ }: Pick) => {
+ return prisma.answer.createMany({
+ data: [
+ ...Array.from(Array(count)).map((_, idx) => ({
+ resultId: `${idPrefix}-result${idx}`,
+ content: `content${idx}`,
+ blockId: 'block1',
+ groupId: 'block1',
+ storageUsed: fakeStorage ? Math.round(fakeStorage / count) : null,
+ })),
+ ],
+ })
+ }
diff --git a/packages/utils/src/pricing.ts b/packages/utils/src/pricing.ts
new file mode 100644
index 00000000000..4ee0e17a6ac
--- /dev/null
+++ b/packages/utils/src/pricing.ts
@@ -0,0 +1,85 @@
+import { Plan, Workspace } from 'db'
+
+const infinity = -1
+
+export const prices = {
+ [Plan.STARTER]: 39,
+ [Plan.PRO]: 89,
+} as const
+
+export const chatsLimit = {
+ [Plan.FREE]: { totalIncluded: 300 },
+ [Plan.STARTER]: {
+ totalIncluded: 2000,
+ increaseStep: {
+ amount: 500,
+ price: 10,
+ },
+ },
+ [Plan.PRO]: {
+ totalIncluded: 10000,
+ increaseStep: {
+ amount: 1000,
+ price: 10,
+ },
+ },
+ [Plan.OFFERED]: { totalIncluded: infinity },
+ [Plan.LIFETIME]: { totalIncluded: infinity },
+} as const
+
+export const storageLimit = {
+ [Plan.FREE]: { totalIncluded: 0 },
+ [Plan.STARTER]: {
+ totalIncluded: 2,
+ increaseStep: {
+ amount: 1,
+ price: 2,
+ },
+ },
+ [Plan.PRO]: {
+ totalIncluded: 10,
+ increaseStep: {
+ amount: 1,
+ price: 2,
+ },
+ },
+ [Plan.OFFERED]: { totalIncluded: 2 },
+ [Plan.LIFETIME]: { totalIncluded: 10 },
+} as const
+
+export const seatsLimit = {
+ [Plan.FREE]: { totalIncluded: 0 },
+ [Plan.STARTER]: {
+ totalIncluded: 2,
+ },
+ [Plan.PRO]: {
+ totalIncluded: 5,
+ },
+ [Plan.OFFERED]: { totalIncluded: 2 },
+ [Plan.LIFETIME]: { totalIncluded: 8 },
+} as const
+
+export const getChatsLimit = ({
+ plan,
+ additionalChatsIndex,
+}: Pick) => {
+ const { totalIncluded } = chatsLimit[plan]
+ const increaseStep =
+ plan === Plan.STARTER || plan === Plan.PRO
+ ? chatsLimit[plan].increaseStep
+ : { amount: 0 }
+ if (totalIncluded === infinity) return infinity
+ return totalIncluded + increaseStep.amount * additionalChatsIndex
+}
+
+export const getStorageLimit = ({
+ plan,
+ additionalStorageIndex,
+}: Pick) => {
+ const { totalIncluded } = storageLimit[plan]
+ const increaseStep =
+ plan === Plan.STARTER || plan === Plan.PRO
+ ? storageLimit[plan].increaseStep
+ : { amount: 0 }
+ return totalIncluded + increaseStep.amount * additionalStorageIndex
+}
diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts
index 836554eaf6b..a0c059c0ae9 100644
--- a/packages/utils/src/utils.ts
+++ b/packages/utils/src/utils.ts
@@ -200,7 +200,7 @@ type UploadFileProps = {
}[]
onUploadProgress?: (percent: number) => void
}
-type UrlList = string[]
+type UrlList = (string | null)[]
export const uploadFiles = async ({
basePath = '/api',
@@ -214,6 +214,7 @@ export const uploadFiles = async ({
i += 1
const { data } = await sendRequest<{
presignedUrl: { url: string; fields: any }
+ hasReachedStorageLimit: boolean
}>(
`${basePath}/storage/upload-url?filePath=${encodeURIComponent(
path
@@ -223,18 +224,21 @@ export const uploadFiles = async ({
if (!data?.presignedUrl) continue
const { url, fields } = data.presignedUrl
- const formData = new FormData()
- Object.entries({ ...fields, file }).forEach(([key, value]) => {
- formData.append(key, value as string | Blob)
- })
- const upload = await fetch(url, {
- method: 'POST',
- body: formData,
- })
-
- if (!upload.ok) continue
-
- urls.push(`${url.split('?')[0]}/${path}`)
+ if (data.hasReachedStorageLimit) urls.push(null)
+ else {
+ const formData = new FormData()
+ Object.entries({ ...fields, file }).forEach(([key, value]) => {
+ formData.append(key, value as string | Blob)
+ })
+ const upload = await fetch(url, {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!upload.ok) continue
+
+ urls.push(`${url.split('?')[0]}/${path}`)
+ }
}
return urls
}
@@ -276,3 +280,6 @@ export const getViewerUrl = (props?: {
: process.env.NEXT_PUBLIC_VERCEL_URL)
)
}
+
+export const parseNumberWithCommas = (num: number) =>
+ num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e1edabaef27..e5860bbea7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -327,6 +327,7 @@ importers:
cors: ^2.8.5
cuid: ^2.1.8
db: workspace:*
+ dotenv: ^16.0.1
eslint: 8.23.0
eslint-config-next: 12.3.0
eslint-plugin-react: ^7.31.8
@@ -334,6 +335,8 @@ importers:
google-auth-library: ^8.5.1
google-spreadsheet: ^3.3.0
got: 12.4.1
+ handlebars: ^4.7.7
+ mjml: ^4.13.0
models: workspace:*
next: 12.3.0
next-transpile-modules: ^9.0.0
@@ -375,11 +378,14 @@ importers:
'@types/sanitize-html': 2.6.2
'@typescript-eslint/eslint-plugin': 5.36.2_2l2r3i3lm6jysqd4ac3ql4n2mm
'@typescript-eslint/parser': 5.36.2_itqs5654cmlnjraw6gjzqacppi
+ dotenv: 16.0.2
eslint: 8.23.0
eslint-config-next: 12.3.0_itqs5654cmlnjraw6gjzqacppi
eslint-plugin-react: 7.31.8_eslint@8.23.0
eslint-plugin-react-hooks: 4.6.0_eslint@8.23.0
google-auth-library: 8.5.1
+ handlebars: 4.7.7
+ mjml: 4.13.0
models: link:../../packages/models
next-transpile-modules: 9.0.0
papaparse: 5.3.2
@@ -570,9 +576,12 @@ importers:
'@rollup/plugin-commonjs': 22.0.2
'@rollup/plugin-node-resolve': ^14.0.1
'@rollup/plugin-typescript': 8.5.0
+ '@types/nodemailer': 6.4.5
aws-sdk: 2.1213.0
+ db: workspace:*
models: workspace:*
next: 12.3.0
+ nodemailer: ^6.7.8
rollup: 2.79.0
rollup-plugin-dts: ^4.2.2
rollup-plugin-peer-deps-external: ^2.2.4
@@ -582,9 +591,12 @@ importers:
'@rollup/plugin-commonjs': 22.0.2_rollup@2.79.0
'@rollup/plugin-node-resolve': 14.0.1_rollup@2.79.0
'@rollup/plugin-typescript': 8.5.0_tznp6w7csbjledp5nresoxoky4
+ '@types/nodemailer': 6.4.5
aws-sdk: 2.1213.0
+ db: link:../db
models: link:../models
next: 12.3.0_biqbaboplfbrettd7655fr4n2y
+ nodemailer: 6.7.8
rollup: 2.79.0
rollup-plugin-dts: 4.2.2_ihkqvyh37tzxtjmovjyy2yrv7m
rollup-plugin-peer-deps-external: 2.2.4_rollup@2.79.0
@@ -8214,6 +8226,10 @@ packages:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true
+ /abbrev/1.1.1:
+ resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ dev: true
+
/abort-controller/3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -8382,6 +8398,11 @@ packages:
string-width: 4.2.3
dev: false
+ /ansi-colors/4.1.3:
+ resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+ engines: {node: '>=6'}
+ dev: true
+
/ansi-escapes/2.0.0:
resolution: {integrity: sha512-tH/fSoQp4DrEodDK3QpdiWiZTSe7sBJ9eOqcQBZ0o9HTM+5M/viSEn+sPMoTuPjQQ8n++w3QJoPEjt8LVPcrCg==}
engines: {node: '>=4'}
@@ -9057,6 +9078,12 @@ packages:
balanced-match: 1.0.2
concat-map: 0.0.1
+ /brace-expansion/2.0.1:
+ resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+ dependencies:
+ balanced-match: 1.0.2
+ dev: true
+
/braces/2.3.2:
resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==}
engines: {node: '>=0.10.0'}
@@ -9278,6 +9305,13 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ /camel-case/3.0.0:
+ resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==}
+ dependencies:
+ no-case: 2.3.2
+ upper-case: 1.1.3
+ dev: true
+
/camel-case/4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
dependencies:
@@ -9368,6 +9402,16 @@ packages:
resolution: {integrity: sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg==}
dev: true
+ /cheerio-select/1.6.0:
+ resolution: {integrity: sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==}
+ dependencies:
+ css-select: 4.3.0
+ css-what: 6.1.0
+ domelementtype: 2.3.0
+ domhandler: 4.3.1
+ domutils: 2.8.0
+ dev: true
+
/cheerio-select/2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
@@ -9377,7 +9421,19 @@ packages:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.0.1
- dev: false
+
+ /cheerio/1.0.0-rc.10:
+ resolution: {integrity: sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==}
+ engines: {node: '>= 6'}
+ dependencies:
+ cheerio-select: 1.6.0
+ dom-serializer: 1.4.1
+ domhandler: 4.3.1
+ htmlparser2: 6.1.0
+ parse5: 6.0.1
+ parse5-htmlparser2-tree-adapter: 6.0.1
+ tslib: 2.4.0
+ dev: true
/cheerio/1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
@@ -9390,7 +9446,6 @@ packages:
htmlparser2: 8.0.1
parse5: 7.0.0
parse5-htmlparser2-tree-adapter: 7.0.0
- dev: false
/chokidar/3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
@@ -9447,6 +9502,13 @@ packages:
resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
dev: false
+ /clean-css/4.2.4:
+ resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
+ engines: {node: '>= 4.0'}
+ dependencies:
+ source-map: 0.6.1
+ dev: true
+
/clean-css/5.3.1:
resolution: {integrity: sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==}
engines: {node: '>= 10.0'}
@@ -9625,7 +9687,6 @@ packages:
/commander/5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'}
- dev: false
/commander/6.2.1:
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
@@ -9695,6 +9756,13 @@ packages:
source-map: 0.6.1
dev: true
+ /config-chain/1.1.13:
+ resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
+ dependencies:
+ ini: 1.3.8
+ proto-list: 1.2.4
+ dev: true
+
/configstore/5.0.1:
resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==}
engines: {node: '>=8'}
@@ -10027,7 +10095,6 @@ packages:
domhandler: 5.0.3
domutils: 3.0.1
nth-check: 2.1.1
- dev: false
/css-to-react-native/3.0.0:
resolution: {integrity: sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==}
@@ -10467,6 +10534,10 @@ packages:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dev: false
+ /detect-node/2.0.4:
+ resolution: {integrity: sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==}
+ dev: true
+
/detect-node/2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dev: false
@@ -10591,7 +10662,6 @@ packages:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.3.1
- dev: false
/domelementtype/2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
@@ -10603,6 +10673,13 @@ packages:
webidl-conversions: 7.0.0
dev: true
+ /domhandler/3.3.0:
+ resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==}
+ engines: {node: '>= 4'}
+ dependencies:
+ domelementtype: 2.3.0
+ dev: true
+
/domhandler/4.3.1:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'}
@@ -10614,7 +10691,6 @@ packages:
engines: {node: '>= 4'}
dependencies:
domelementtype: 2.3.0
- dev: false
/dompurify/2.3.9:
resolution: {integrity: sha512-3zOnuTwup4lPV/GfGS6UzG4ub9nhSYagR/5tB3AvDEwqyy5dtyCM2dVjwGDCnrPerXifBKTYh/UWCGKK7ydhhw==}
@@ -10633,7 +10709,6 @@ packages:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
- dev: false
/dot-case/3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -10690,6 +10765,16 @@ packages:
dependencies:
safe-buffer: 5.2.1
+ /editorconfig/0.15.3:
+ resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==}
+ hasBin: true
+ dependencies:
+ commander: 2.20.3
+ lru-cache: 4.1.5
+ semver: 5.7.1
+ sigmund: 1.0.1
+ dev: true
+
/ee-first/1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
@@ -10837,6 +10922,11 @@ packages:
engines: {node: '>=8'}
dev: false
+ /escape-goat/3.0.0:
+ resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==}
+ engines: {node: '>=10'}
+ dev: true
+
/escape-html/1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: false
@@ -12034,6 +12124,17 @@ packages:
once: 1.4.0
path-is-absolute: 1.0.1
+ /glob/8.0.3:
+ resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 5.1.0
+ once: 1.4.0
+ dev: true
+
/global-dirs/3.0.0:
resolution: {integrity: sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==}
engines: {node: '>=10'}
@@ -12273,6 +12374,19 @@ packages:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
dev: false
+ /handlebars/4.7.7:
+ resolution: {integrity: sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==}
+ engines: {node: '>=0.4.7'}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ neo-async: 2.6.2
+ source-map: 0.6.1
+ wordwrap: 1.0.0
+ optionalDependencies:
+ uglify-js: 3.17.0
+ dev: true
+
/has-bigints/1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
@@ -12426,7 +12540,6 @@ packages:
/he/1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
- dev: false
/hey-listen/1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
@@ -12507,6 +12620,20 @@ packages:
terser: 5.14.2
dev: false
+ /html-minifier/4.0.0:
+ resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==}
+ engines: {node: '>=6'}
+ hasBin: true
+ dependencies:
+ camel-case: 3.0.0
+ clean-css: 4.2.4
+ commander: 2.20.3
+ he: 1.2.0
+ param-case: 2.1.1
+ relateurl: 0.2.7
+ uglify-js: 3.17.0
+ dev: true
+
/html-tags/3.2.0:
resolution: {integrity: sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==}
engines: {node: '>=8'}
@@ -12530,6 +12657,15 @@ packages:
webpack: 5.74.0
dev: false
+ /htmlparser2/4.1.0:
+ resolution: {integrity: sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 3.3.0
+ domutils: 2.8.0
+ entities: 2.2.0
+ dev: true
+
/htmlparser2/6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
dependencies:
@@ -12545,7 +12681,6 @@ packages:
domhandler: 5.0.3
domutils: 3.0.1
entities: 4.3.1
- dev: false
/http-cache-semantics/4.1.0:
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
@@ -12781,7 +12916,6 @@ packages:
/ini/1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
- dev: false
/ini/2.0.0:
resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
@@ -13898,6 +14032,17 @@ packages:
react: 18.2.0
dev: false
+ /js-beautify/1.14.6:
+ resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==}
+ engines: {node: '>=10'}
+ hasBin: true
+ dependencies:
+ config-chain: 1.1.13
+ editorconfig: 0.15.3
+ glob: 8.0.3
+ nopt: 6.0.0
+ dev: true
+
/js-cookie/2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
dev: false
@@ -14077,6 +14222,20 @@ packages:
object.assign: 4.1.2
dev: true
+ /juice/7.0.0:
+ resolution: {integrity: sha512-AjKQX31KKN+uJs+zaf+GW8mBO/f/0NqSh2moTMyvwBY+4/lXIYTU8D8I2h6BAV3Xnz6GGsbalUyFqbYMe+Vh+Q==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ dependencies:
+ cheerio: 1.0.0-rc.12
+ commander: 5.1.0
+ mensch: 0.3.4
+ slick: 1.12.2
+ web-resource-inliner: 5.0.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
/jwa/1.4.1:
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
dependencies:
@@ -14360,6 +14519,10 @@ packages:
dependencies:
js-tokens: 4.0.0
+ /lower-case/1.1.4:
+ resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==}
+ dev: true
+
/lower-case/2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
dependencies:
@@ -14381,6 +14544,13 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
+ /lru-cache/4.1.5:
+ resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}
+ dependencies:
+ pseudomap: 1.0.2
+ yallist: 2.1.2
+ dev: true
+
/lru-cache/6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@@ -14515,6 +14685,10 @@ packages:
fs-monkey: 1.0.3
dev: false
+ /mensch/0.3.4:
+ resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==}
+ dev: true
+
/merge-descriptors/1.0.1:
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: false
@@ -14610,6 +14784,12 @@ packages:
hasBin: true
dev: false
+ /mime/2.6.0:
+ resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
+ engines: {node: '>=4.0.0'}
+ hasBin: true
+ dev: true
+
/mimic-fn/1.2.0:
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
engines: {node: '>=4'}
@@ -14675,6 +14855,13 @@ packages:
dependencies:
brace-expansion: 1.1.11
+ /minimatch/5.1.0:
+ resolution: {integrity: sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==}
+ engines: {node: '>=10'}
+ dependencies:
+ brace-expansion: 2.0.1
+ dev: true
+
/minimist/1.2.6:
resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
@@ -14709,6 +14896,369 @@ packages:
is-extendable: 1.0.1
dev: false
+ /mjml-accordion/4.13.0:
+ resolution: {integrity: sha512-E3yihZW5Oq2p+sWOcr8kWeRTROmiTYOGxB4IOxW/jTycdY07N3FX3e6vuh7Fv3rryHEUaydUQYto3ICVyctI7w==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-body/4.13.0:
+ resolution: {integrity: sha512-S4HgwAuO9dEsyX9sr6WBf9/xr+H2ASVaLn22aurJm1S2Lvc1wifLPYBQgFmNdCjaesTCNtOMUDpG+Rbnavyaqg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-button/4.13.0:
+ resolution: {integrity: sha512-3y8IAHCCxh7ESHh1aOOqobZKUgyNxOKAGQ9TlJoyaLpsKUFzkN8nmrD0KXF0ADSuzvhMZ1CdRIJuZ5mjv2TwWQ==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-carousel/4.13.0:
+ resolution: {integrity: sha512-ORSY5bEYlMlrWSIKI/lN0Tz3uGltWAjG8DQl2Yr3pwjwOaIzGE+kozrDf+T9xItfiIIbvKajef1dg7B7XgP0zg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-cli/4.13.0:
+ resolution: {integrity: sha512-kAZxpH0QqlTF/CcLzELgKw1ljKRxrmWJ310CJQhbPAxHvwQ/nIb+q82U+zRJAelRPPKjnOb+hSrMRqTgk9rH3w==}
+ hasBin: true
+ dependencies:
+ '@babel/runtime': 7.18.6
+ chokidar: 3.5.3
+ glob: 7.2.3
+ html-minifier: 4.0.0
+ js-beautify: 1.14.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ mjml-migrate: 4.13.0
+ mjml-parser-xml: 4.13.0
+ mjml-validator: 4.13.0
+ yargs: 16.2.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-column/4.13.0:
+ resolution: {integrity: sha512-O8FrWKK/bCy9XpKxrKRYWNdgWNaVd4TK4RqMeVI/I70IbnYnc1uf15jnsPMxCBSbT+NyXyk8k7fn099797uwpw==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-core/4.13.0:
+ resolution: {integrity: sha512-kU5AoVTlZaXR/EDi3ix66xpzUe+kScYus71lBH/wo/B+LZW70GHE1AYWtsog5oJp1MuTHpMFTNuBD/wePeEgWg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ cheerio: 1.0.0-rc.10
+ detect-node: 2.0.4
+ html-minifier: 4.0.0
+ js-beautify: 1.14.6
+ juice: 7.0.0
+ lodash: 4.17.21
+ mjml-migrate: 4.13.0
+ mjml-parser-xml: 4.13.0
+ mjml-validator: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-divider/4.13.0:
+ resolution: {integrity: sha512-ooPCwfmxEC+wJduqObYezMp7W5UCHjL9Y1LPB5FGna2FrOejgfd6Ix3ij8Wrmycmlol7E2N4D7c5NDH5DbRCJg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-group/4.13.0:
+ resolution: {integrity: sha512-U7E8m8aaoAE/dMqjqXPjjrKcwO36B4cquAy9ASldECrIZJBcpFYO6eYf5yLXrNCUM2P0id8pgVjrUq23s00L7Q==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-attributes/4.13.0:
+ resolution: {integrity: sha512-haggCafno+0lQylxJStkINCVCPMwfTpwE6yjCHeGOpQl/TkoNmjNkDr7DEEbNTZbt4Ekg070lQFn7clDy38EoA==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-breakpoint/4.13.0:
+ resolution: {integrity: sha512-D2iPDeUKQK1+rYSNa2HGOvgfPxZhNyndTG0iBEb/FxdGge2hbeDCZEN0mwDYE3wWB+qSBqlCuMI+Vr4pEjZbKg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-font/4.13.0:
+ resolution: {integrity: sha512-mYn8aWnbrEap5vX2b4662hkUv6WifcYzYn++Yi6OHrJQi55LpzcU+myAGpfQEXXrpU8vGwExMTFKsJq5n2Kaow==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-html-attributes/4.13.0:
+ resolution: {integrity: sha512-m30Oro297+18Zou/1qYjagtmCOWtYXeoS38OABQ5zOSzMItE3TcZI9JNcOueIIWIyFCETe8StrTAKcQ2GHwsDw==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-preview/4.13.0:
+ resolution: {integrity: sha512-v0K/NocjFCbaoF/0IMVNmiqov91HxqT07vNTEl0Bt9lKFrTKVC01m1S4K7AB78T/bEeJ/HwmNjr1+TMtVNGGow==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-style/4.13.0:
+ resolution: {integrity: sha512-tBa33GL9Atn5bAM2UwE+uxv4rI29WgX/e5lXX+5GWlsb4thmiN6rxpFTNqBqWbBNRbZk4UEZF78M7Da8xC1ZGQ==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head-title/4.13.0:
+ resolution: {integrity: sha512-Mq0bjuZXJlwxfVcjuYihQcigZSDTKeQaG3nORR1D0jsOH2BXU4XgUK1UOcTXn2qCBIfRoIMq7rfzYs+L0CRhdw==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-head/4.13.0:
+ resolution: {integrity: sha512-sL2qQuoVALXBCiemu4DPo9geDr8DuUdXVJxm+4nd6k5jpLCfSDmFlNhgSsLPzsYn7VEac3/sxsjLtomQ+6/BHg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-hero/4.13.0:
+ resolution: {integrity: sha512-aWEOScdrhyjwdKBWG4XQaElRHP8LU5PtktkpMeBXa4yxrxNs25qRnDqMNkjSrnnmFKWZmQ166tfboY6RBNf0UA==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-image/4.13.0:
+ resolution: {integrity: sha512-agMmm2wRZTIrKwrUnYFlnAbtrKYSP0R2en+Vf92HPspAwmaw3/AeOW/QxmSiMhfGf+xsEJyzVvR/nd33jbT3sg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-migrate/4.13.0:
+ resolution: {integrity: sha512-I1euHiAyNpaz+B5vH+Z4T+hg/YtI5p3PqQ3/zTLv8gi24V6BILjTaftWhH5+3R/gQkQhH0NUaWNnRmds+Mq5DQ==}
+ hasBin: true
+ dependencies:
+ '@babel/runtime': 7.18.6
+ js-beautify: 1.14.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ mjml-parser-xml: 4.13.0
+ yargs: 16.2.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-navbar/4.13.0:
+ resolution: {integrity: sha512-0Oqyyk+OdtXfsjswRb/7Ql1UOjN4MbqFPKoyltJqtj+11MRpF5+Wjd74Dj9H7l81GFwkIB9OaP+ZMiD+TPECgg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-parser-xml/4.13.0:
+ resolution: {integrity: sha512-phljtI8DaW++q0aybR/Ykv9zCyP/jCFypxVNo26r2IQo//VYXyc7JuLZZT8N/LAI8lZcwbTVxQPBzJTmZ5IfwQ==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ detect-node: 2.0.4
+ htmlparser2: 4.1.0
+ lodash: 4.17.21
+ dev: true
+
+ /mjml-preset-core/4.13.0:
+ resolution: {integrity: sha512-gxzYaKkvUrHuzT1oqjEPSDtdmgEnN99Hf5f1r2CR5aMOB1x66EA3T8ATvF1o7qrBTVV4KMVlQem3IubMSYJZRw==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ mjml-accordion: 4.13.0
+ mjml-body: 4.13.0
+ mjml-button: 4.13.0
+ mjml-carousel: 4.13.0
+ mjml-column: 4.13.0
+ mjml-divider: 4.13.0
+ mjml-group: 4.13.0
+ mjml-head: 4.13.0
+ mjml-head-attributes: 4.13.0
+ mjml-head-breakpoint: 4.13.0
+ mjml-head-font: 4.13.0
+ mjml-head-html-attributes: 4.13.0
+ mjml-head-preview: 4.13.0
+ mjml-head-style: 4.13.0
+ mjml-head-title: 4.13.0
+ mjml-hero: 4.13.0
+ mjml-image: 4.13.0
+ mjml-navbar: 4.13.0
+ mjml-raw: 4.13.0
+ mjml-section: 4.13.0
+ mjml-social: 4.13.0
+ mjml-spacer: 4.13.0
+ mjml-table: 4.13.0
+ mjml-text: 4.13.0
+ mjml-wrapper: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-raw/4.13.0:
+ resolution: {integrity: sha512-JbBYxwX1a/zbqnCrlDCRNqov2xqUrMCaEdTHfqE2athj479aQXvLKFM20LilTMaClp/dR0yfvFLfFVrC5ej4FQ==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-section/4.13.0:
+ resolution: {integrity: sha512-BLcqlhavtRakKtzDQPLv6Ae4Jt4imYWq/P0jo+Sjk7tP4QifgVA2KEQOirPK5ZUqw/lvK7Afhcths5rXZ2ItnQ==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-social/4.13.0:
+ resolution: {integrity: sha512-zL2a7Wwsk8OXF0Bqu+1B3La1UPwdTMcEXptO8zdh2V5LL6Xb7Gfyvx6w0CmmBtG5IjyCtqaKy5wtrcpG9Hvjfg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-spacer/4.13.0:
+ resolution: {integrity: sha512-Acw4QJ0MJ38W4IewXuMX7hLaW1BZaln+gEEuTfrv0xwPdTxX1ILqz4r+s9mYMxYkIDLWMCjBvXyQK6aWlid13A==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-table/4.13.0:
+ resolution: {integrity: sha512-UAWPVMaGReQhf776DFdiwdcJTIHTek3zzQ1pb+E7VlypEYgIpFvdUJ39UIiiflhqtdBATmHwKBOtePwU0MzFMg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-text/4.13.0:
+ resolution: {integrity: sha512-uDuraaQFdu+6xfuigCimbeznnOnJfwRdcCL1lTBTusTuEvW/5Va6m2D3mnMeEpl+bp4+cxesXIz9st6A9pcg5A==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml-validator/4.13.0:
+ resolution: {integrity: sha512-uURYfyQYtHJ6Qz/1A7/+E9ezfcoISoLZhYK3olsxKRViwaA2Mm8gy/J3yggZXnsUXWUns7Qymycm5LglLEIiQg==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ dev: true
+
+ /mjml-wrapper/4.13.0:
+ resolution: {integrity: sha512-p/44JvHg04rAFR7QDImg8nZucEokIjFH6KJMHxsO0frJtLZ+IuakctzlZAADHsqiR52BwocDsXSa+o9SE2l6Ng==}
+ dependencies:
+ '@babel/runtime': 7.18.6
+ lodash: 4.17.21
+ mjml-core: 4.13.0
+ mjml-section: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /mjml/4.13.0:
+ resolution: {integrity: sha512-OnFKESouLshz8DPFSb6M/dE8GkhiJnoy6LAam5TiLA1anAj24yQ2ZH388LtQoEkvTisqwiTmc9ejDh5ctnFaJQ==}
+ hasBin: true
+ dependencies:
+ '@babel/runtime': 7.18.6
+ mjml-cli: 4.13.0
+ mjml-core: 4.13.0
+ mjml-migrate: 4.13.0
+ mjml-preset-core: 4.13.0
+ mjml-validator: 4.13.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
/mkdirp/0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@@ -14925,6 +15475,12 @@ packages:
- babel-plugin-macros
dev: false
+ /no-case/2.3.2:
+ resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==}
+ dependencies:
+ lower-case: 1.1.4
+ dev: true
+
/no-case/3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
dependencies:
@@ -14970,7 +15526,14 @@ packages:
/nodemailer/6.7.8:
resolution: {integrity: sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g==}
engines: {node: '>=6.0.0'}
- dev: false
+
+ /nopt/6.0.0:
+ resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==}
+ engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ hasBin: true
+ dependencies:
+ abbrev: 1.1.1
+ dev: true
/normalize-package-data/2.5.0:
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
@@ -15337,6 +15900,12 @@ packages:
/papaparse/5.3.2:
resolution: {integrity: sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==}
+ /param-case/2.1.1:
+ resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==}
+ dependencies:
+ no-case: 2.3.2
+ dev: true
+
/param-case/3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
dependencies:
@@ -15388,16 +15957,20 @@ packages:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
dev: false
+ /parse5-htmlparser2-tree-adapter/6.0.1:
+ resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==}
+ dependencies:
+ parse5: 6.0.1
+ dev: true
+
/parse5-htmlparser2-tree-adapter/7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
domhandler: 5.0.3
parse5: 7.0.0
- dev: false
/parse5/6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
- dev: false
/parse5/7.0.0:
resolution: {integrity: sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==}
@@ -16454,6 +17027,10 @@ packages:
xtend: 4.0.2
dev: false
+ /proto-list/1.2.4:
+ resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
+ dev: true
+
/proxy-addr/2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -16470,6 +17047,10 @@ packages:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false
+ /pseudomap/1.0.2:
+ resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
+ dev: true
+
/psl/1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
dev: true
@@ -17186,7 +17767,6 @@ packages:
/relateurl/0.2.7:
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
engines: {node: '>= 0.10'}
- dev: false
/remark-emoji/2.2.0:
resolution: {integrity: sha512-P3cj9s5ggsUvWw5fS2uzCHJMGuXYRb0NnZqYlNecewXt8QBU9n5vW3DUUKOhepS8F9CwdMx9B8a3i7pqFWAI5w==}
@@ -17864,6 +18444,10 @@ packages:
get-intrinsic: 1.1.2
object-inspect: 1.12.2
+ /sigmund/1.0.1:
+ resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==}
+ dev: true
+
/signal-exit/3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -17951,6 +18535,10 @@ packages:
is-fullwidth-code-point: 2.0.0
dev: true
+ /slick/1.12.2:
+ resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
+ dev: true
+
/snapdragon-node/2.1.1:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'}
@@ -19083,6 +19671,12 @@ packages:
resolution: {integrity: sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==}
dev: false
+ /uglify-js/3.17.0:
+ resolution: {integrity: sha512-aTeNPVmgIMPpm1cxXr2Q/nEbvkmV8yq66F3om7X3P/cvOXQ0TMQ64Wk63iyT1gPlmdmGzjGpyLh1f3y8MZWXGg==}
+ engines: {node: '>=0.8.0'}
+ hasBin: true
+ dev: true
+
/unbox-primitive/1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies:
@@ -19269,6 +19863,10 @@ packages:
xdg-basedir: 4.0.0
dev: false
+ /upper-case/1.1.3:
+ resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==}
+ dev: true
+
/uri-js/4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies:
@@ -19493,6 +20091,11 @@ packages:
convert-source-map: 1.8.0
dev: true
+ /valid-data-url/3.0.1:
+ resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==}
+ engines: {node: '>=10'}
+ dev: true
+
/validate-npm-package-license/3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
dependencies:
@@ -19597,6 +20200,20 @@ packages:
resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==}
dev: false
+ /web-resource-inliner/5.0.0:
+ resolution: {integrity: sha512-AIihwH+ZmdHfkJm7BjSXiEClVt4zUFqX4YlFAzjL13wLtDuUneSaFvDBTbdYRecs35SiU7iNKbMnN+++wVfb6A==}
+ engines: {node: '>=10.0.0'}
+ dependencies:
+ ansi-colors: 4.1.3
+ escape-goat: 3.0.0
+ htmlparser2: 4.1.0
+ mime: 2.6.0
+ node-fetch: 2.6.7
+ valid-data-url: 3.0.1
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
/webidl-conversions/3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -19872,6 +20489,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /wordwrap/1.0.0:
+ resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
+ dev: true
+
/wrap-ansi/7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -19998,6 +20619,10 @@ packages:
engines: {node: '>=10'}
dev: true
+ /yallist/2.1.2:
+ resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==}
+ dev: true
+
/yallist/4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@@ -20005,11 +20630,29 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
+ /yargs-parser/20.2.9:
+ resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+ engines: {node: '>=10'}
+ dev: true
+
/yargs-parser/21.0.1:
resolution: {integrity: sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==}
engines: {node: '>=12'}
dev: true
+ /yargs/16.2.0:
+ resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
+ engines: {node: '>=10'}
+ dependencies:
+ cliui: 7.0.4
+ escalade: 3.1.1
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 20.2.9
+ dev: true
+
/yargs/17.5.1:
resolution: {integrity: sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==}
engines: {node: '>=12'}