✨ Add usage-based new pricing plans
This commit is contained in:
committed by
Baptiste Arnaud
parent
6a1eaea700
commit
898367a33b
@@ -2,7 +2,7 @@ import { Flex, Spinner, useDisclosure } from '@chakra-ui/react'
|
||||
import { StatsCards } from 'components/analytics/StatsCards'
|
||||
import { Graph } from 'components/shared/Graph'
|
||||
import { useToast } from 'components/shared/hooks/useToast'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
|
||||
import { GraphProvider, GroupsCoordinatesProvider } from 'contexts/GraphContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { Stats } from 'models'
|
||||
@@ -49,7 +49,7 @@ export const AnalyticsContent = ({ stats }: { stats?: Stats }) => {
|
||||
<Spinner color="gray" />
|
||||
</Flex>
|
||||
)}
|
||||
<UpgradeModal onClose={onClose} isOpen={isOpen} />
|
||||
<ChangePlanModal onClose={onClose} isOpen={isOpen} />
|
||||
<StatsCards stats={stats} pos="absolute" top={10} />
|
||||
</Flex>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DashboardFolder, WorkspaceRole } from 'db'
|
||||
import { env } from 'utils'
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
@@ -160,9 +161,13 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
|
||||
return (
|
||||
<Flex w="full" flex="1" justify="center">
|
||||
{typebots && !isTypebotLoading && user && folder === null && (
|
||||
<OnboardingModal totalTypebots={typebots.length} />
|
||||
)}
|
||||
{typebots &&
|
||||
!isTypebotLoading &&
|
||||
user &&
|
||||
folder === null &&
|
||||
env('E2E_TEST') !== 'true' && (
|
||||
<OnboardingModal totalTypebots={typebots.length} />
|
||||
)}
|
||||
<Stack w="1000px" spacing={6}>
|
||||
<Skeleton isLoaded={folder?.name !== undefined}>
|
||||
<Heading as="h1">{folder?.name}</Heading>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
|
||||
import { FolderPlusIcon } from 'assets/icons'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
|
||||
import {
|
||||
LimitReached,
|
||||
ChangePlanModal,
|
||||
} from 'components/shared/modals/ChangePlanModal'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React from 'react'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
@@ -26,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
<Text>Create a folder</Text>
|
||||
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
|
||||
</HStack>
|
||||
<UpgradeModal
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.FOLDER}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { ChangePlanForm } from 'components/shared/ChangePlanForm'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
import { CurrentSubscriptionContent } from './CurrentSubscriptionContent'
|
||||
import { InvoicesList } from './InvoicesList'
|
||||
import { UsageContent } from './UsageContent/UsageContent'
|
||||
|
||||
export const BillingContent = () => {
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
|
||||
if (!workspace) return null
|
||||
return (
|
||||
<Stack spacing="10" w="full">
|
||||
<CurrentSubscriptionContent
|
||||
plan={workspace.plan}
|
||||
stripeId={workspace.stripeId}
|
||||
onCancelSuccess={() =>
|
||||
refreshWorkspace({
|
||||
plan: Plan.FREE,
|
||||
additionalChatsIndex: 0,
|
||||
additionalStorageIndex: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<UsageContent workspace={workspace} />
|
||||
{workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.OFFERED && (
|
||||
<ChangePlanForm />
|
||||
)}
|
||||
{workspace.stripeId && <InvoicesList workspace={workspace} />}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Text,
|
||||
HStack,
|
||||
Link,
|
||||
Spinner,
|
||||
Stack,
|
||||
Flex,
|
||||
Button,
|
||||
} from '@chakra-ui/react'
|
||||
import { PlanTag } from 'components/shared/PlanTag'
|
||||
import { Plan } from 'db'
|
||||
import React, { useState } from 'react'
|
||||
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
|
||||
|
||||
type CurrentSubscriptionContentProps = {
|
||||
plan: Plan
|
||||
stripeId?: string | null
|
||||
onCancelSuccess: () => void
|
||||
}
|
||||
|
||||
export const CurrentSubscriptionContent = ({
|
||||
plan,
|
||||
stripeId,
|
||||
onCancelSuccess,
|
||||
}: CurrentSubscriptionContentProps) => {
|
||||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
|
||||
useState(false)
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
if (!stripeId) return
|
||||
setIsCancelling(true)
|
||||
await cancelSubscriptionQuery(stripeId)
|
||||
onCancelSuccess()
|
||||
setIsCancelling(false)
|
||||
}
|
||||
|
||||
if (isCancelling) return <Spinner colorScheme="gray" />
|
||||
return (
|
||||
<Stack gap="2">
|
||||
<HStack>
|
||||
<Text>Current workspace subscription: </Text>
|
||||
<PlanTag plan={plan} />
|
||||
</HStack>
|
||||
|
||||
{(plan === Plan.STARTER || plan === Plan.PRO) && stripeId && (
|
||||
<>
|
||||
<Stack gap="1">
|
||||
<Text fontSize="sm">
|
||||
Need to change payment method or billing information? Head over to
|
||||
your billing portal:
|
||||
</Text>
|
||||
<Button
|
||||
as={Link}
|
||||
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
|
||||
onClick={() => setIsRedirectingToBillingPortal(true)}
|
||||
isLoading={isRedirectingToBillingPortal}
|
||||
>
|
||||
Billing Portal
|
||||
</Button>
|
||||
</Stack>
|
||||
<Flex>
|
||||
<Link
|
||||
as="button"
|
||||
color="gray.500"
|
||||
textDecor="underline"
|
||||
fontSize="sm"
|
||||
onClick={cancelSubscription}
|
||||
>
|
||||
Cancel my subscription
|
||||
</Link>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Stack,
|
||||
Heading,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
IconButton,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { DownloadIcon, FileIcon } from 'assets/icons'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { Workspace } from 'db'
|
||||
import React from 'react'
|
||||
import { useInvoicesQuery } from './queries/useInvoicesQuery'
|
||||
|
||||
type Props = {
|
||||
workspace: Workspace
|
||||
}
|
||||
|
||||
export const InvoicesList = ({ workspace }: Props) => {
|
||||
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<Heading fontSize="3xl">Invoices</Heading>
|
||||
{invoices.length === 0 && !isLoading ? (
|
||||
<Text>No invoices found for this workspace.</Text>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="0" />
|
||||
<Th>#</Th>
|
||||
<Th>Paid at</Th>
|
||||
<Th>Subtotal</Th>
|
||||
<Th w="0" />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{invoices?.map((invoice) => (
|
||||
<Tr key={invoice.id}>
|
||||
<Td>
|
||||
<FileIcon />
|
||||
</Td>
|
||||
<Td>{invoice.id}</Td>
|
||||
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
|
||||
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
as={NextChakraLink}
|
||||
size="xs"
|
||||
icon={<DownloadIcon />}
|
||||
variant="outline"
|
||||
href={invoice.url}
|
||||
isExternal
|
||||
aria-label={'Download invoice'}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{isLoading &&
|
||||
Array.from({ length: 3 }).map((_, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td>
|
||||
<Checkbox isDisabled />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton h="5px" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton h="5px" />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const getFormattedPrice = (amount: number, currency: string) => {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
})
|
||||
|
||||
return formatter.format(amount / 100)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Stack,
|
||||
Flex,
|
||||
Heading,
|
||||
Progress,
|
||||
Text,
|
||||
Skeleton,
|
||||
HStack,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react'
|
||||
import { AlertIcon } from 'assets/icons'
|
||||
import { Plan, Workspace } from 'db'
|
||||
import React from 'react'
|
||||
import { getChatsLimit, getStorageLimit, parseNumberWithCommas } from 'utils'
|
||||
import { storageToReadable } from './helpers'
|
||||
import { useUsage } from './useUsage'
|
||||
|
||||
type Props = {
|
||||
workspace: Workspace
|
||||
}
|
||||
|
||||
export const UsageContent = ({ workspace }: Props) => {
|
||||
const { data, isLoading } = useUsage(workspace.id)
|
||||
const totalChatsUsed = data?.totalChatsUsed ?? 0
|
||||
const totalStorageUsed = data?.totalStorageUsed ?? 0
|
||||
|
||||
const workspaceChatsLimit = getChatsLimit(workspace)
|
||||
const workspaceStorageLimit = getStorageLimit(workspace)
|
||||
const workspaceStorageLimitGigabites =
|
||||
workspaceStorageLimit * 1024 * 1024 * 1024
|
||||
|
||||
const chatsPercentage = Math.round(
|
||||
(totalChatsUsed / workspaceChatsLimit) * 100
|
||||
)
|
||||
const storagePercentage = Math.round(
|
||||
(totalStorageUsed / workspaceStorageLimitGigabites) * 100
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<Heading fontSize="3xl">Usage</Heading>
|
||||
<Stack spacing={3}>
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack>
|
||||
<Heading fontSize="xl" as="h3">
|
||||
Chats
|
||||
</Heading>
|
||||
{chatsPercentage >= 80 && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
rounded="md"
|
||||
p="3"
|
||||
label={
|
||||
<Text>
|
||||
Your typebots are popular! You will soon reach your plan's
|
||||
chats limit. 🚀
|
||||
<br />
|
||||
<br />
|
||||
Make sure to <strong>update your plan</strong> to increase
|
||||
this limit and continue chatting with your users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<AlertIcon color="orange.500" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text fontSize="sm" fontStyle="italic" color="gray.500">
|
||||
(resets on 1st of every month)
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Skeleton
|
||||
fontWeight="bold"
|
||||
isLoaded={!isLoading}
|
||||
h={isLoading ? '5px' : 'auto'}
|
||||
>
|
||||
{parseNumberWithCommas(totalChatsUsed)}
|
||||
</Skeleton>
|
||||
<Text>/ {parseNumberWithCommas(workspaceChatsLimit)}</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
<Progress
|
||||
h="5px"
|
||||
value={chatsPercentage}
|
||||
rounded="full"
|
||||
hasStripe
|
||||
isIndeterminate={isLoading}
|
||||
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
|
||||
/>
|
||||
</Stack>
|
||||
{workspace.plan !== Plan.FREE && (
|
||||
<Stack spacing={3}>
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack>
|
||||
<Heading fontSize="xl" as="h3">
|
||||
Storage
|
||||
</Heading>
|
||||
{storagePercentage >= 80 && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
rounded="md"
|
||||
p="3"
|
||||
label={
|
||||
<Text>
|
||||
Your typebots are popular! You will soon reach your plan's
|
||||
storage limit. 🚀
|
||||
<br />
|
||||
<br />
|
||||
Make sure to <strong>update your plan</strong> in order to
|
||||
continue collecting uploaded files. You can also{' '}
|
||||
<strong>delete files</strong> to free up space.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<AlertIcon color="orange.500" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Heading
|
||||
fontSize="xl"
|
||||
as="h3"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap="2"
|
||||
></Heading>
|
||||
<HStack>
|
||||
<Skeleton
|
||||
fontWeight="bold"
|
||||
isLoaded={!isLoading}
|
||||
h={isLoading ? '5px' : 'auto'}
|
||||
>
|
||||
{storageToReadable(totalStorageUsed)}
|
||||
</Skeleton>
|
||||
<Text>/ {workspaceStorageLimit} GB</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Progress
|
||||
value={storagePercentage}
|
||||
h="5px"
|
||||
colorScheme={
|
||||
totalStorageUsed >= workspaceStorageLimitGigabites
|
||||
? 'red'
|
||||
: 'blue'
|
||||
}
|
||||
rounded="full"
|
||||
hasStripe
|
||||
isIndeterminate={isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const storageToReadable = (bytes: number) => {
|
||||
if (bytes == 0) {
|
||||
return '0'
|
||||
}
|
||||
const e = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UsageContent } from './UsageContent'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { env } from 'utils'
|
||||
|
||||
export const useUsage = (workspaceId?: string) => {
|
||||
const { data, error } = useSWR<
|
||||
{ totalChatsUsed: number; totalStorageUsed: number },
|
||||
Error
|
||||
>(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
|
||||
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
|
||||
})
|
||||
return {
|
||||
data,
|
||||
isLoading: !error && !data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingContent } from './BillingContent'
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const cancelSubscriptionQuery = (stripeId: string) =>
|
||||
sendRequest({
|
||||
url: `api/stripe/subscription?stripeId=${stripeId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const redirectToBillingPortal = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { env } from 'utils'
|
||||
|
||||
type Invoice = {
|
||||
id: string
|
||||
url: string
|
||||
date: number
|
||||
currency: string
|
||||
amount: number
|
||||
}
|
||||
export const useInvoicesQuery = (stripeId?: string | null) => {
|
||||
const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
|
||||
stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
|
||||
}
|
||||
)
|
||||
return {
|
||||
invoices: data?.invoices ?? [],
|
||||
isLoading: !error && !data,
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
|
||||
import { ExternalLinkIcon } from 'assets/icons'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
|
||||
export const BillingForm = () => {
|
||||
const { workspace } = useWorkspace()
|
||||
|
||||
return (
|
||||
<Stack spacing="6" w="full">
|
||||
<HStack>
|
||||
<Text>Current workspace subscription: </Text>
|
||||
<PlanTag plan={workspace?.plan} />
|
||||
</HStack>
|
||||
{workspace &&
|
||||
!([Plan.TEAM, Plan.LIFETIME, Plan.OFFERED] as Plan[]).includes(
|
||||
workspace.plan
|
||||
) && (
|
||||
<HStack>
|
||||
{workspace?.plan === Plan.FREE && (
|
||||
<UpgradeButton colorScheme="orange" variant="outline" w="full">
|
||||
Upgrade to Pro plan
|
||||
</UpgradeButton>
|
||||
)}
|
||||
{workspace?.plan !== Plan.TEAM && (
|
||||
<UpgradeButton
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
w="full"
|
||||
plan={Plan.TEAM}
|
||||
>
|
||||
Upgrade to Team plan
|
||||
</UpgradeButton>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
{workspace?.stripeId && (
|
||||
<>
|
||||
<Text>
|
||||
To manage your subscription and download invoices, head over to your
|
||||
Stripe portal:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
|
||||
isExternal
|
||||
colorScheme="blue"
|
||||
rightIcon={<ExternalLinkIcon />}
|
||||
>
|
||||
Stripe Portal
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const PlanTag = ({ plan }: { plan?: Plan }) => {
|
||||
switch (plan) {
|
||||
case Plan.TEAM: {
|
||||
return <Tag colorScheme="purple">Team</Tag>
|
||||
}
|
||||
case Plan.LIFETIME:
|
||||
case Plan.OFFERED:
|
||||
case Plan.PRO: {
|
||||
return <Tag colorScheme="orange">Personal Pro</Tag>
|
||||
}
|
||||
default: {
|
||||
return <Tag colorScheme="gray">Free</Tag>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
|
||||
import { WorkspaceInvitation, WorkspaceRole } from 'db'
|
||||
import React from 'react'
|
||||
import {
|
||||
deleteInvitation,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useMembers,
|
||||
} from 'services/workspace'
|
||||
import { AddMemberForm } from './AddMemberForm'
|
||||
import { checkCanInviteMember } from './helpers'
|
||||
import { MemberItem } from './MemberItem'
|
||||
|
||||
export const MembersList = () => {
|
||||
@@ -78,14 +79,19 @@ export const MembersList = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const canInviteNewMember = checkCanInviteMember({
|
||||
plan: workspace?.plan,
|
||||
currentMembersCount: [...(members ?? []), ...(invitations ?? [])].length,
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack w="full">
|
||||
{workspace?.plan !== Plan.TEAM && (
|
||||
<Stack w="full" gap="3">
|
||||
{!canInviteNewMember && (
|
||||
<UnlockPlanInfo
|
||||
contentLabel={
|
||||
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
|
||||
}
|
||||
plan={Plan.TEAM}
|
||||
contentLabel={`
|
||||
Upgrade your plan to work with more team members, and unlock awesome
|
||||
power features 🚀
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{workspace?.id && canEdit && (
|
||||
@@ -94,7 +100,7 @@ export const MembersList = () => {
|
||||
onNewInvitation={handleNewInvitation}
|
||||
onNewMember={handleNewMember}
|
||||
isLoading={isLoading}
|
||||
isLocked={workspace.plan !== Plan.TEAM}
|
||||
isLocked={!canInviteNewMember}
|
||||
/>
|
||||
)}
|
||||
{members?.map((member) => (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Plan } from 'db'
|
||||
import { seatsLimit } from 'utils'
|
||||
|
||||
export function checkCanInviteMember({
|
||||
plan,
|
||||
currentMembersCount,
|
||||
}: {
|
||||
plan: string | undefined
|
||||
currentMembersCount?: number
|
||||
}) {
|
||||
if (!plan || !currentMembersCount) return false
|
||||
if (plan !== Plan.STARTER && plan !== Plan.PRO) return false
|
||||
|
||||
return seatsLimit[plan].totalIncluded > currentMembersCount
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { User, Workspace } from 'db'
|
||||
import { useState } from 'react'
|
||||
import { BillingForm } from './BillingForm'
|
||||
import { BillingContent } from './BillingContent'
|
||||
import { MembersList } from './MembersList'
|
||||
import { MyAccountForm } from './MyAccountForm'
|
||||
import { EditorSettingsForm } from './EditorSettingsForm'
|
||||
@@ -50,13 +50,12 @@ export const WorkspaceSettingsModal = ({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent h="600px" flexDir="row">
|
||||
<ModalContent minH="600px" flexDir="row">
|
||||
<Stack
|
||||
spacing={8}
|
||||
w="250px"
|
||||
w="200px"
|
||||
py="6"
|
||||
borderRightWidth={1}
|
||||
h="full"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack spacing={8}>
|
||||
@@ -134,7 +133,7 @@ export const WorkspaceSettingsModal = ({
|
||||
justifyContent="flex-start"
|
||||
pl="4"
|
||||
>
|
||||
Billing
|
||||
Billing & Usage
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -174,7 +173,7 @@ const SettingsContent = ({
|
||||
case 'members':
|
||||
return <MembersList />
|
||||
case 'billing':
|
||||
return <BillingForm />
|
||||
return <BillingContent />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { SubmissionsTable } from 'components/results/ResultsTable'
|
||||
import React, { useState } from 'react'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { LogsModal } from './LogsModal'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { Plan } from 'db'
|
||||
import { useResults } from 'contexts/ResultsProvider'
|
||||
import { ResultModal } from './ResultModal'
|
||||
|
||||
@@ -14,7 +12,6 @@ export const ResultsContent = () => {
|
||||
fetchMore,
|
||||
hasMore,
|
||||
resultHeader,
|
||||
totalHiddenResults,
|
||||
tableData,
|
||||
} = useResults()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
@@ -46,13 +43,6 @@ export const ResultsContent = () => {
|
||||
overflow="scroll"
|
||||
w="full"
|
||||
>
|
||||
{totalHiddenResults && (
|
||||
<UnlockPlanInfo
|
||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
||||
contentLabel="You are seeing complete submissions only."
|
||||
plan={Plan.PRO}
|
||||
/>
|
||||
)}
|
||||
{publishedTypebot && (
|
||||
<LogsModal
|
||||
typebotId={publishedTypebot?.typebotId}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const ResultsActionButtons = ({
|
||||
resultHeader,
|
||||
mutate,
|
||||
totalResults,
|
||||
totalHiddenResults,
|
||||
tableData,
|
||||
onDeleteResults,
|
||||
} = useResults()
|
||||
@@ -57,7 +56,7 @@ export const ResultsActionButtons = ({
|
||||
|
||||
const totalSelected =
|
||||
selectedResultsId.length > 0 && selectedResultsId.length === results?.length
|
||||
? totalResults - (totalHiddenResults ?? 0)
|
||||
? totalResults
|
||||
: selectedResultsId.length
|
||||
|
||||
const deleteResults = async () => {
|
||||
@@ -87,9 +86,7 @@ export const ResultsActionButtons = ({
|
||||
|
||||
const exportResultsToCSV = async () => {
|
||||
setIsExportLoading(true)
|
||||
const isSelectAll =
|
||||
totalSelected === 0 ||
|
||||
totalSelected === totalResults - (totalHiddenResults ?? 0)
|
||||
const isSelectAll = totalSelected === 0 || totalSelected === totalResults
|
||||
|
||||
const dataToUnparse = isSelectAll
|
||||
? await getAllTableData()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Tag,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { ChangePlanModal } from 'components/shared/modals/ChangePlanModal'
|
||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { GeneralSettings } from 'models'
|
||||
@@ -56,7 +56,7 @@ export const GeneralSettingsForm = ({
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} />
|
||||
<ChangePlanModal isOpen={isOpen} onClose={onClose} />
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
align="center"
|
||||
@@ -82,7 +82,11 @@ export const GeneralSettingsForm = ({
|
||||
<SwitchWithLabel
|
||||
id="new-result"
|
||||
label="Remember session"
|
||||
initialValue={isDefined(generalSettings.isNewResultOnRefreshEnabled) ? !generalSettings.isNewResultOnRefreshEnabled : true}
|
||||
initialValue={
|
||||
isDefined(generalSettings.isNewResultOnRefreshEnabled)
|
||||
? !generalSettings.isNewResultOnRefreshEnabled
|
||||
: true
|
||||
}
|
||||
onCheckChange={handleNewResultOnRefreshChange}
|
||||
moreInfoContent="If the user refreshes the page, its existing results will be overwritten. Disable this if you want to create a new results every time the user refreshes the page."
|
||||
/>
|
||||
|
||||
108
apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
Normal file
108
apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Stack, HStack, Text } from '@chakra-ui/react'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
import { ProPlanContent } from './ProPlanContent'
|
||||
import { pay } from './queries/updatePlan'
|
||||
import { useCurrentSubscriptionInfo } from './queries/useCurrentSubscriptionInfo'
|
||||
import { StarterPlanContent } from './StarterPlanContent'
|
||||
|
||||
export const ChangePlanForm = () => {
|
||||
const { user } = useUser()
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
const { showToast } = useToast()
|
||||
const { data, mutate: refreshCurrentSubscriptionInfo } =
|
||||
useCurrentSubscriptionInfo({
|
||||
stripeId: workspace?.stripeId,
|
||||
plan: workspace?.plan,
|
||||
})
|
||||
|
||||
const handlePayClick = async ({
|
||||
plan,
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
}: {
|
||||
plan: 'STARTER' | 'PRO'
|
||||
selectedChatsLimitIndex: number
|
||||
selectedStorageLimitIndex: number
|
||||
}) => {
|
||||
if (
|
||||
!user ||
|
||||
!workspace ||
|
||||
selectedChatsLimitIndex === undefined ||
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return
|
||||
await pay({
|
||||
stripeId: workspace.stripeId ?? undefined,
|
||||
user,
|
||||
plan,
|
||||
workspaceId: workspace.id,
|
||||
additionalChats: selectedChatsLimitIndex,
|
||||
additionalStorage: selectedStorageLimitIndex,
|
||||
})
|
||||
refreshCurrentSubscriptionInfo({
|
||||
additionalChatsIndex: selectedChatsLimitIndex,
|
||||
additionalStorageIndex: selectedStorageLimitIndex,
|
||||
})
|
||||
refreshWorkspace({
|
||||
plan,
|
||||
additionalChatsIndex: selectedChatsLimitIndex,
|
||||
additionalStorageIndex: selectedStorageLimitIndex,
|
||||
})
|
||||
showToast({
|
||||
status: 'success',
|
||||
description: `Workspace ${plan} plan successfully updated 🎉`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
spacing="4"
|
||||
w="full"
|
||||
pt={
|
||||
workspace?.plan === Plan.STARTER || workspace?.plan === Plan.PRO
|
||||
? '10'
|
||||
: '0'
|
||||
}
|
||||
>
|
||||
<StarterPlanContent
|
||||
initialChatsLimitIndex={
|
||||
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0
|
||||
}
|
||||
initialStorageLimitIndex={
|
||||
workspace?.plan === Plan.STARTER ? data?.additionalStorageIndex : 0
|
||||
}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||
}
|
||||
/>
|
||||
|
||||
<ProPlanContent
|
||||
initialChatsLimitIndex={
|
||||
workspace?.plan === Plan.PRO ? data?.additionalChatsIndex : 0
|
||||
}
|
||||
initialStorageLimitIndex={
|
||||
workspace?.plan === Plan.PRO ? data?.additionalStorageIndex : 0
|
||||
}
|
||||
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
||||
/>
|
||||
</HStack>
|
||||
<Text color="gray.500">
|
||||
Need custom limits? Specific features?{' '}
|
||||
<NextChakraLink
|
||||
href={'https://typebot.io/enterprise-lead-form'}
|
||||
isExternal
|
||||
textDecor="underline"
|
||||
>
|
||||
Let me know
|
||||
</NextChakraLink>
|
||||
.
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
339
apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
Normal file
339
apps/builder/components/shared/ChangePlanForm/ProPlanContent.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
Stack,
|
||||
Heading,
|
||||
chakra,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
Button,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
Tooltip,
|
||||
Flex,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
chatsLimit,
|
||||
getChatsLimit,
|
||||
getStorageLimit,
|
||||
storageLimit,
|
||||
parseNumberWithCommas,
|
||||
} from 'utils'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
import { FeaturesList } from './components/FeaturesList'
|
||||
import { computePrice, formatPrice } from './helpers'
|
||||
|
||||
type ProPlanContentProps = {
|
||||
initialChatsLimitIndex?: number
|
||||
initialStorageLimitIndex?: number
|
||||
onPayClick: (props: {
|
||||
selectedChatsLimitIndex: number
|
||||
selectedStorageLimitIndex: number
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
export const ProPlanContent = ({
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
onPayClick,
|
||||
}: ProPlanContentProps) => {
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
useState<number>()
|
||||
const [isPaying, setIsPaying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined &&
|
||||
initialChatsLimitIndex !== undefined
|
||||
)
|
||||
setSelectedChatsLimitIndex(initialChatsLimitIndex)
|
||||
if (
|
||||
selectedStorageLimitIndex === undefined &&
|
||||
initialStorageLimitIndex !== undefined
|
||||
)
|
||||
setSelectedStorageLimitIndex(initialStorageLimitIndex)
|
||||
}, [
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
const workspaceStorageLimit = workspace
|
||||
? getStorageLimit(workspace)
|
||||
: undefined
|
||||
|
||||
console.log('workspaceChatsLimit', workspaceChatsLimit)
|
||||
console.log('workspaceStorageLimit', workspace)
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.PRO].totalIncluded +
|
||||
chatsLimit[Plan.PRO].increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0) ===
|
||||
workspaceChatsLimit &&
|
||||
storageLimit[Plan.PRO].totalIncluded +
|
||||
storageLimit[Plan.PRO].increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0) ===
|
||||
workspaceStorageLimit
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined ||
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return ''
|
||||
if (workspace?.plan === Plan.PRO) {
|
||||
if (isCurrentPlan) return 'Your current plan'
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
)
|
||||
return 'Update'
|
||||
}
|
||||
return 'Upgrade'
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined ||
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return
|
||||
setIsPaying(true)
|
||||
await onPayClick({
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
})
|
||||
setIsPaying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
p="6"
|
||||
pos="relative"
|
||||
h="full"
|
||||
flexDir="column"
|
||||
flex="1"
|
||||
flexShrink={0}
|
||||
borderWidth="1px"
|
||||
borderColor="blue.500"
|
||||
rounded="lg"
|
||||
>
|
||||
<Flex justifyContent="center">
|
||||
<Tag
|
||||
pos="absolute"
|
||||
top="-10px"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
fontWeight="semibold"
|
||||
style={{ marginTop: 0 }}
|
||||
>
|
||||
Most popular
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Stack justifyContent="space-between" h="full">
|
||||
<Stack spacing="4" mt={2}>
|
||||
<Heading fontSize="2xl">
|
||||
Upgrade to <chakra.span color="blue.400">Pro</chakra.span>
|
||||
</Heading>
|
||||
<Text>For agencies & growing startups.</Text>
|
||||
</Stack>
|
||||
<Stack spacing="4">
|
||||
<Heading>
|
||||
{formatPrice(
|
||||
computePrice(
|
||||
Plan.PRO,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
) ?? NaN
|
||||
)}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Text fontWeight="bold">
|
||||
<Tooltip
|
||||
label={
|
||||
<FeaturesList
|
||||
features={[
|
||||
'Branding removed',
|
||||
'File upload input block',
|
||||
'Create folders',
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
Everything in Starter
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
, plus:
|
||||
</Text>
|
||||
<FeaturesList
|
||||
features={[
|
||||
'5 seats included',
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedChatsLimitIndex === undefined}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0)
|
||||
)}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
chats/mo
|
||||
</Text>
|
||||
<MoreInfoTooltip>
|
||||
A chat is counted whenever a user starts a discussion. It is
|
||||
independant of the number of messages he sends and receives.
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedStorageLimitIndex === undefined}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0)
|
||||
)}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(0)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(1)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(2)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(3)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(4)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
GB of storage
|
||||
</Text>
|
||||
<MoreInfoTooltip>
|
||||
You accumulate storage for every file that your user upload
|
||||
into your bot. If you delete the result, it will free up the
|
||||
space.
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
'Custom domains',
|
||||
'In-depth analytics',
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handlePayClick}
|
||||
isLoading={isPaying}
|
||||
isDisabled={isCurrentPlan}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
Stack,
|
||||
Heading,
|
||||
chakra,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
Button,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
chatsLimit,
|
||||
getChatsLimit,
|
||||
getStorageLimit,
|
||||
storageLimit,
|
||||
parseNumberWithCommas,
|
||||
} from 'utils'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
import { FeaturesList } from './components/FeaturesList'
|
||||
import { computePrice, formatPrice } from './helpers'
|
||||
|
||||
type StarterPlanContentProps = {
|
||||
initialChatsLimitIndex?: number
|
||||
initialStorageLimitIndex?: number
|
||||
onPayClick: (props: {
|
||||
selectedChatsLimitIndex: number
|
||||
selectedStorageLimitIndex: number
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
export const StarterPlanContent = ({
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
onPayClick,
|
||||
}: StarterPlanContentProps) => {
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
useState<number>()
|
||||
const [isPaying, setIsPaying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined &&
|
||||
initialChatsLimitIndex !== undefined
|
||||
)
|
||||
setSelectedChatsLimitIndex(initialChatsLimitIndex)
|
||||
if (
|
||||
selectedStorageLimitIndex === undefined &&
|
||||
initialStorageLimitIndex !== undefined
|
||||
)
|
||||
setSelectedStorageLimitIndex(initialStorageLimitIndex)
|
||||
}, [
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
const workspaceStorageLimit = workspace
|
||||
? getStorageLimit(workspace)
|
||||
: undefined
|
||||
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.STARTER].totalIncluded +
|
||||
chatsLimit[Plan.STARTER].increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0) ===
|
||||
workspaceChatsLimit &&
|
||||
storageLimit[Plan.STARTER].totalIncluded +
|
||||
storageLimit[Plan.STARTER].increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0) ===
|
||||
workspaceStorageLimit
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined ||
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return ''
|
||||
if (workspace?.plan === Plan.PRO) return 'Downgrade'
|
||||
if (workspace?.plan === Plan.STARTER) {
|
||||
if (isCurrentPlan) return 'Your current plan'
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
)
|
||||
return 'Update'
|
||||
}
|
||||
return 'Upgrade'
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined ||
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return
|
||||
setIsPaying(true)
|
||||
await onPayClick({
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
})
|
||||
setIsPaying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
|
||||
<Stack spacing="4">
|
||||
<Heading fontSize="2xl">
|
||||
Upgrade to <chakra.span color="orange.400">Starter</chakra.span>
|
||||
</Heading>
|
||||
<Text>For individuals & small businesses.</Text>
|
||||
<Heading>
|
||||
{formatPrice(
|
||||
computePrice(
|
||||
Plan.STARTER,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
) ?? NaN
|
||||
)}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
</Heading>
|
||||
<FeaturesList
|
||||
features={[
|
||||
'2 seats included',
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedChatsLimitIndex === undefined}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0)
|
||||
)}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
chats/mo
|
||||
</Text>
|
||||
<MoreInfoTooltip>
|
||||
A chat is counted whenever a user starts a discussion. It is
|
||||
independant of the number of messages he sends and receives.
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedStorageLimitIndex === undefined}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0)
|
||||
)}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
GB of storage
|
||||
</Text>
|
||||
<MoreInfoTooltip>
|
||||
You accumulate storage for every file that your user upload into
|
||||
your bot. If you delete the result, it will free up the space.
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
'Branding removed',
|
||||
'File upload input block',
|
||||
'Create folders',
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
onClick={handlePayClick}
|
||||
isLoading={isPaying}
|
||||
isDisabled={isCurrentPlan}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
ListProps,
|
||||
UnorderedList,
|
||||
Flex,
|
||||
ListItem,
|
||||
ListIcon,
|
||||
} from '@chakra-ui/react'
|
||||
import { CheckIcon } from 'assets/icons'
|
||||
|
||||
type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
|
||||
|
||||
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
|
||||
<UnorderedList listStyleType="none" spacing={2} {...props}>
|
||||
{features.map((feat, idx) => (
|
||||
<Flex as={ListItem} key={idx} alignItems="center">
|
||||
<ListIcon as={CheckIcon} />
|
||||
{feat}
|
||||
</Flex>
|
||||
))}
|
||||
</UnorderedList>
|
||||
)
|
||||
86
apps/builder/components/shared/ChangePlanForm/helpers.ts
Normal file
86
apps/builder/components/shared/ChangePlanForm/helpers.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Plan } from 'db'
|
||||
import { chatsLimit, prices, storageLimit } from 'utils'
|
||||
|
||||
export const computePrice = (
|
||||
plan: Plan,
|
||||
selectedTotalChatsIndex: number,
|
||||
selectedTotalStorageIndex: number
|
||||
) => {
|
||||
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
|
||||
const {
|
||||
increaseStep: { price: chatsPrice },
|
||||
} = chatsLimit[plan]
|
||||
const {
|
||||
increaseStep: { price: storagePrice },
|
||||
} = storageLimit[plan]
|
||||
return (
|
||||
prices[plan] +
|
||||
selectedTotalChatsIndex * chatsPrice +
|
||||
selectedTotalStorageIndex * storagePrice
|
||||
)
|
||||
}
|
||||
|
||||
const europeanUnionCountryCodes = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DE',
|
||||
'DK',
|
||||
'EE',
|
||||
'ES',
|
||||
'FI',
|
||||
'FR',
|
||||
'GR',
|
||||
'HR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LT',
|
||||
'LU',
|
||||
'LV',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SE',
|
||||
'SI',
|
||||
'SK',
|
||||
]
|
||||
|
||||
const europeanUnionExclusiveLanguageCodes = [
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'el',
|
||||
'pl',
|
||||
'fi',
|
||||
'nl',
|
||||
'hr',
|
||||
'cs',
|
||||
'hu',
|
||||
'ro',
|
||||
'sl',
|
||||
'sv',
|
||||
'bg',
|
||||
]
|
||||
|
||||
export const guessIfUserIsEuropean = () =>
|
||||
navigator.languages.some((language) => {
|
||||
const [languageCode, countryCode] = language.split('-')
|
||||
return countryCode
|
||||
? europeanUnionCountryCodes.includes(countryCode)
|
||||
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
|
||||
})
|
||||
|
||||
export const formatPrice = (price: number) => {
|
||||
const isEuropean = guessIfUserIsEuropean()
|
||||
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: isEuropean ? 'EUR' : 'USD',
|
||||
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
|
||||
})
|
||||
return formatter.format(price)
|
||||
}
|
||||
1
apps/builder/components/shared/ChangePlanForm/index.ts
Normal file
1
apps/builder/components/shared/ChangePlanForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ChangePlanForm } from './ChangePlanForm'
|
||||
@@ -0,0 +1,66 @@
|
||||
import { loadStripe } from '@stripe/stripe-js/pure'
|
||||
import { Plan, User } from 'db'
|
||||
import { env, isDefined, isEmpty, sendRequest } from 'utils'
|
||||
import { guessIfUserIsEuropean } from '../helpers'
|
||||
|
||||
type UpgradeProps = {
|
||||
user: User
|
||||
stripeId?: string
|
||||
plan: Plan
|
||||
workspaceId: string
|
||||
additionalChats: number
|
||||
additionalStorage: number
|
||||
}
|
||||
|
||||
export const pay = async ({
|
||||
stripeId,
|
||||
...props
|
||||
}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> =>
|
||||
isDefined(stripeId)
|
||||
? updatePlan({ ...props, stripeId })
|
||||
: redirectToCheckout(props)
|
||||
|
||||
export const updatePlan = async ({
|
||||
stripeId,
|
||||
plan,
|
||||
workspaceId,
|
||||
additionalChats,
|
||||
additionalStorage,
|
||||
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
|
||||
const { data, error } = await sendRequest<{ message: string }>({
|
||||
method: 'PUT',
|
||||
url: '/api/stripe/subscription',
|
||||
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
|
||||
})
|
||||
if (error || !data) return
|
||||
return { newPlan: plan }
|
||||
}
|
||||
|
||||
export const redirectToCheckout = async ({
|
||||
user,
|
||||
plan,
|
||||
workspaceId,
|
||||
additionalChats,
|
||||
additionalStorage,
|
||||
}: Omit<UpgradeProps, 'customerId'>) => {
|
||||
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
|
||||
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
|
||||
const { data, error } = await sendRequest<{ sessionId: string }>({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/subscription',
|
||||
body: {
|
||||
email: user.email,
|
||||
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
|
||||
plan,
|
||||
workspaceId,
|
||||
href: location.origin + location.pathname,
|
||||
additionalChats,
|
||||
additionalStorage,
|
||||
},
|
||||
})
|
||||
if (error || !data) return
|
||||
const stripe = await loadStripe(env('STRIPE_PUBLIC_KEY') as string)
|
||||
await stripe?.redirectToCheckout({
|
||||
sessionId: data?.sessionId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Plan } from 'db'
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
|
||||
export const useCurrentSubscriptionInfo = ({
|
||||
stripeId,
|
||||
plan,
|
||||
}: {
|
||||
stripeId?: string | null
|
||||
plan?: Plan
|
||||
}) => {
|
||||
const { data, mutate } = useSWR<
|
||||
{
|
||||
additionalChatsIndex: number
|
||||
additionalStorageIndex: number
|
||||
},
|
||||
Error
|
||||
>(
|
||||
stripeId && (plan === Plan.STARTER || plan === Plan.PRO)
|
||||
? `/api/stripe/subscription?stripeId=${stripeId}`
|
||||
: null,
|
||||
fetcher
|
||||
)
|
||||
return {
|
||||
data: !stripeId
|
||||
? { additionalChatsIndex: 0, additionalStorageIndex: 0 }
|
||||
: data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
import { UpgradeModal } from './modals/UpgradeModal'
|
||||
import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
|
||||
import { ChangePlanModal } from './modals/ChangePlanModal'
|
||||
import { LimitReached } from './modals/ChangePlanModal'
|
||||
|
||||
export const Info = (props: AlertProps) => (
|
||||
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
|
||||
@@ -27,30 +26,34 @@ export const UnlockPlanInfo = ({
|
||||
contentLabel,
|
||||
buttonLabel = 'More info',
|
||||
type,
|
||||
plan = Plan.PRO,
|
||||
...props
|
||||
}: {
|
||||
contentLabel: string
|
||||
contentLabel: React.ReactNode
|
||||
buttonLabel?: string
|
||||
type?: LimitReached
|
||||
plan: Plan
|
||||
}) => {
|
||||
} & AlertProps) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
return (
|
||||
<Alert
|
||||
status="info"
|
||||
bgColor={'blue.50'}
|
||||
rounded="md"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
{...props}
|
||||
>
|
||||
<HStack>
|
||||
<AlertIcon />
|
||||
<Text>{contentLabel}</Text>
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
|
||||
<Button
|
||||
colorScheme={props.status === 'warning' ? 'orange' : 'blue'}
|
||||
onClick={onOpen}
|
||||
flexShrink={0}
|
||||
ml="2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
||||
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ type Props = {
|
||||
|
||||
export const MoreInfoTooltip = ({ children }: Props) => {
|
||||
return (
|
||||
<Tooltip label={children}>
|
||||
<Tooltip label={children} hasArrow rounded="md" p="3">
|
||||
<chakra.span cursor="pointer">
|
||||
<HelpCircleIcon />
|
||||
</chakra.span>
|
||||
|
||||
30
apps/builder/components/shared/PlanTag.tsx
Normal file
30
apps/builder/components/shared/PlanTag.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Tag } from '@chakra-ui/react'
|
||||
import { Plan } from 'db'
|
||||
|
||||
export const PlanTag = ({ plan }: { plan?: Plan }) => {
|
||||
switch (plan) {
|
||||
case Plan.LIFETIME:
|
||||
case Plan.PRO: {
|
||||
return (
|
||||
<Tag colorScheme="blue" data-testid="plan-tag">
|
||||
Pro
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case Plan.OFFERED:
|
||||
case Plan.STARTER: {
|
||||
return (
|
||||
<Tag colorScheme="orange" data-testid="plan-tag">
|
||||
Starter
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<Tag colorScheme="gray" data-testid="plan-tag">
|
||||
Free
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,12 @@ import {
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import { InputBlockType } from 'models'
|
||||
import { useRouter } from 'next/router'
|
||||
import { timeSince } from 'services/utils'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { UpgradeModal } from '../modals/UpgradeModal'
|
||||
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
|
||||
import { LimitReached, ChangePlanModal } from '../modals/ChangePlanModal'
|
||||
|
||||
export const PublishButton = (props: ButtonProps) => {
|
||||
const { workspace } = useWorkspace()
|
||||
@@ -50,8 +48,7 @@ export const PublishButton = (props: ButtonProps) => {
|
||||
|
||||
return (
|
||||
<HStack spacing="1px">
|
||||
<UpgradeModal
|
||||
plan={Plan.PRO}
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.FILE_INPUT}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { UpgradeModal } from '../modals/UpgradeModal'
|
||||
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
|
||||
import { ChangePlanModal } from '../modals/ChangePlanModal'
|
||||
import { LimitReached } from '../modals/ChangePlanModal'
|
||||
|
||||
type Props = { plan?: Plan; type?: LimitReached } & ButtonProps
|
||||
type Props = { type?: LimitReached } & ButtonProps
|
||||
|
||||
export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
|
||||
export const UpgradeButton = ({ type, ...props }: Props) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { workspace } = useWorkspace()
|
||||
return (
|
||||
@@ -19,7 +18,7 @@ export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
|
||||
onClick={onOpen}
|
||||
>
|
||||
{props.children ?? 'Upgrade'}
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
||||
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const UploadButton = ({
|
||||
},
|
||||
],
|
||||
})
|
||||
if (urls.length) onFileUploaded(urls[0])
|
||||
if (urls.length && urls[0]) onFileUploaded(urls[0])
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ export const useToast = () => {
|
||||
title,
|
||||
description,
|
||||
status = 'error',
|
||||
...props
|
||||
}: UseToastOptions) => {
|
||||
toast({
|
||||
position: 'bottom-right',
|
||||
description,
|
||||
title,
|
||||
status,
|
||||
...props,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
53
apps/builder/components/shared/modals/ChangePlanModal.tsx
Normal file
53
apps/builder/components/shared/modals/ChangePlanModal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
Button,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { Info } from 'components/shared/Info'
|
||||
import { ChangePlanForm } from 'components/shared/ChangePlanForm'
|
||||
|
||||
export enum LimitReached {
|
||||
BRAND = 'remove branding',
|
||||
CUSTOM_DOMAIN = 'add custom domain',
|
||||
FOLDER = 'create folders',
|
||||
FILE_INPUT = 'use file input blocks',
|
||||
}
|
||||
|
||||
type ChangePlanModalProps = {
|
||||
type?: LimitReached
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ChangePlanModal = ({
|
||||
onClose,
|
||||
isOpen,
|
||||
type,
|
||||
}: ChangePlanModalProps) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalBody as={Stack} spacing="6" pt="10">
|
||||
{type && (
|
||||
<Info>You need to upgrade your plan in order to {type}</Info>
|
||||
)}
|
||||
<ChangePlanForm />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Button, ButtonProps } from '@chakra-ui/react'
|
||||
import * as React from 'react'
|
||||
|
||||
export const ActionButton = (props: ButtonProps) => (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
w="full"
|
||||
fontWeight="extrabold"
|
||||
py={{ md: '8' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react'
|
||||
import * as React from 'react'
|
||||
import { CardBadge } from './CardBadge'
|
||||
|
||||
export interface CardProps extends BoxProps {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
export const Card = (props: CardProps) => {
|
||||
const { children, isPopular, ...rest } = props
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
position="relative"
|
||||
px="6"
|
||||
pb="6"
|
||||
pt="16"
|
||||
overflow="hidden"
|
||||
shadow="lg"
|
||||
maxW="md"
|
||||
width="100%"
|
||||
{...rest}
|
||||
>
|
||||
{isPopular && <CardBadge>Popular</CardBadge>}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Flex, FlexProps, Text, useColorModeValue } from '@chakra-ui/react'
|
||||
import * as React from 'react'
|
||||
|
||||
export const CardBadge = (props: FlexProps) => {
|
||||
const { children, ...flexProps } = props
|
||||
return (
|
||||
<Flex
|
||||
bg={useColorModeValue('green.500', 'green.200')}
|
||||
position="absolute"
|
||||
right={-20}
|
||||
top={6}
|
||||
width="240px"
|
||||
transform="rotate(45deg)"
|
||||
py={2}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
{...flexProps}
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
letterSpacing="wider"
|
||||
color={useColorModeValue('white', 'gray.800')}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
List,
|
||||
ListIcon,
|
||||
ListItem,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { CheckIcon } from 'assets/icons'
|
||||
import * as React from 'react'
|
||||
import { Card, CardProps } from './Card'
|
||||
|
||||
export interface PricingCardData {
|
||||
features: string[]
|
||||
name: string
|
||||
price: string
|
||||
}
|
||||
|
||||
interface PricingCardProps extends CardProps {
|
||||
data: PricingCardData
|
||||
button: React.ReactElement
|
||||
}
|
||||
|
||||
export const PricingCard = (props: PricingCardProps) => {
|
||||
const { data, button, ...rest } = props
|
||||
const { features, price, name } = data
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.200')
|
||||
|
||||
return (
|
||||
<Card rounded={{ sm: 'xl' }} {...rest}>
|
||||
<VStack spacing={6}>
|
||||
<Heading size="md" fontWeight="extrabold">
|
||||
{name}
|
||||
</Heading>
|
||||
</VStack>
|
||||
<Flex
|
||||
align="flex-end"
|
||||
justify="center"
|
||||
fontWeight="extrabold"
|
||||
color={accentColor}
|
||||
my="8"
|
||||
>
|
||||
<Heading size="3xl" fontWeight="inherit" lineHeight="0.9em">
|
||||
{price}
|
||||
</Heading>
|
||||
<Text fontWeight="inherit" fontSize="2xl">
|
||||
/ mo
|
||||
</Text>
|
||||
</Flex>
|
||||
<List spacing="4" mb="8" maxW="30ch" mx="auto">
|
||||
{features.map((feature, index) => (
|
||||
<ListItem fontWeight="medium" key={index}>
|
||||
<ListIcon
|
||||
fontSize="xl"
|
||||
as={CheckIcon}
|
||||
marginEnd={2}
|
||||
color={accentColor}
|
||||
/>
|
||||
{feature}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{button}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
Text,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
ListItem,
|
||||
UnorderedList,
|
||||
ListIcon,
|
||||
chakra,
|
||||
Tooltip,
|
||||
ListProps,
|
||||
Button,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { pay } from 'services/stripe'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { Plan } from 'db'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { CheckIcon } from 'assets/icons'
|
||||
import { toTitleCase } from 'utils'
|
||||
import { useToast } from 'components/shared/hooks/useToast'
|
||||
import { Info } from 'components/shared/Info'
|
||||
|
||||
export enum LimitReached {
|
||||
BRAND = 'remove branding',
|
||||
CUSTOM_DOMAIN = 'add custom domain',
|
||||
FOLDER = 'create folders',
|
||||
FILE_INPUT = 'use file input blocks',
|
||||
}
|
||||
|
||||
type UpgradeModalProps = {
|
||||
type?: LimitReached
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
plan?: Plan
|
||||
}
|
||||
|
||||
export const UpgradeModal = ({
|
||||
onClose,
|
||||
isOpen,
|
||||
type,
|
||||
plan = Plan.PRO,
|
||||
}: UpgradeModalProps) => {
|
||||
const { user } = useUser()
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
const [payLoading, setPayLoading] = useState(false)
|
||||
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
|
||||
const { showToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
setCurrency(
|
||||
navigator.languages.find((l) => l.includes('fr')) ? 'eur' : 'usd'
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (!user || !workspace) return
|
||||
setPayLoading(true)
|
||||
const response = await pay({
|
||||
customerId: workspace.stripeId ?? undefined,
|
||||
user,
|
||||
currency,
|
||||
plan: plan === Plan.TEAM ? 'team' : 'pro',
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
setPayLoading(false)
|
||||
if (response?.newPlan) {
|
||||
refreshWorkspace({ plan: response.newPlan })
|
||||
showToast({
|
||||
status: 'success',
|
||||
title: 'Upgrade success!',
|
||||
description: `Workspace successfully upgraded to ${toTitleCase(
|
||||
response.newPlan
|
||||
)} plan 🎉`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalBody as={Stack} pt="10">
|
||||
{plan === Plan.PRO ? (
|
||||
<PersonalProPlanContent currency={currency} type={type} />
|
||||
) : (
|
||||
<TeamPlanContent currency={currency} type={type} />
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePayClick}
|
||||
isLoading={payLoading}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const PersonalProPlanContent = ({
|
||||
currency,
|
||||
type,
|
||||
}: {
|
||||
currency: 'eur' | 'usd'
|
||||
type?: LimitReached
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
<Info>You need to upgrade your plan in order to {type}</Info>
|
||||
<TypebotLogo boxSize="30px" />
|
||||
<Heading fontSize="2xl">
|
||||
Upgrade to <chakra.span color="orange.400">Personal Pro</chakra.span>{' '}
|
||||
plan
|
||||
</Heading>
|
||||
<Text>For solo creators who want to do even more.</Text>
|
||||
<Heading>
|
||||
{currency === 'eur' ? '39€' : '$39'}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Text fontWeight="bold">Everything in Personal, plus:</Text>
|
||||
<FeatureList
|
||||
features={[
|
||||
'Branding removed',
|
||||
'View incomplete submissions',
|
||||
'In-depth drop off analytics',
|
||||
'Unlimited custom domains',
|
||||
'Organize typebots in folders',
|
||||
'Unlimited uploads',
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const TeamPlanContent = ({
|
||||
currency,
|
||||
type,
|
||||
}: {
|
||||
currency: 'eur' | 'usd'
|
||||
type?: LimitReached
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
<Info>You need to upgrade your plan in order to {type}</Info>
|
||||
<TypebotLogo boxSize="30px" />
|
||||
<Heading fontSize="2xl">
|
||||
Upgrade to <chakra.span color="purple.400">Team</chakra.span> plan
|
||||
</Heading>
|
||||
<Text>For teams to build typebots together in one spot.</Text>
|
||||
<Heading>
|
||||
{currency === 'eur' ? '99€' : '$99'}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Text fontWeight="bold">
|
||||
<Tooltip
|
||||
label={
|
||||
<FeatureList
|
||||
features={[
|
||||
'Branding removed',
|
||||
'View incomplete submissions',
|
||||
'In-depth drop off analytics',
|
||||
'Custom domains',
|
||||
'Organize typebots in folders',
|
||||
'Unlimited uploads',
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
Everything in Pro
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
, plus:
|
||||
</Text>
|
||||
<FeatureList
|
||||
features={[
|
||||
'Unlimited team members',
|
||||
'Collaborative workspace',
|
||||
'Custom roles',
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const FeatureList = ({
|
||||
features,
|
||||
...props
|
||||
}: { features: string[] } & ListProps) => (
|
||||
<UnorderedList listStyleType="none" spacing={2} {...props}>
|
||||
{features.map((feat) => (
|
||||
<ListItem key={feat}>
|
||||
<ListIcon as={CheckIcon} />
|
||||
{feat}
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export { UpgradeModal } from './UpgradeModal'
|
||||
1
apps/builder/components/shared/modals/index.ts
Normal file
1
apps/builder/components/shared/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ChangePlanModal } from './ChangePlanModal'
|
||||
Reference in New Issue
Block a user