feat(dashboard): 🛂 Limit create folder to Pro user
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { DashboardFolder } from '.prisma/client'
|
import { DashboardFolder } from '.prisma/client'
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Flex,
|
Flex,
|
||||||
Heading,
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
Wrap,
|
Wrap,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { FolderPlusIcon } from 'assets/icons'
|
|
||||||
import { useTypebotDnd } from 'contexts/TypebotDndContext'
|
import { useTypebotDnd } from 'contexts/TypebotDndContext'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
@@ -19,6 +17,7 @@ import { createFolder, useFolders } from 'services/folders'
|
|||||||
import { patchTypebot, useTypebots } from 'services/typebots'
|
import { patchTypebot, useTypebots } from 'services/typebots'
|
||||||
import { BackButton } from './FolderContent/BackButton'
|
import { BackButton } from './FolderContent/BackButton'
|
||||||
import { CreateBotButton } from './FolderContent/CreateBotButton'
|
import { CreateBotButton } from './FolderContent/CreateBotButton'
|
||||||
|
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
|
||||||
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
|
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
|
||||||
import { TypebotButton } from './FolderContent/TypebotButton'
|
import { TypebotButton } from './FolderContent/TypebotButton'
|
||||||
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
|
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
|
||||||
@@ -152,13 +151,10 @@ export const FolderContent = ({ folder }: Props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<HStack>
|
<HStack>
|
||||||
{folder && <BackButton id={folder.parentFolderId} />}
|
{folder && <BackButton id={folder.parentFolderId} />}
|
||||||
<Button
|
<CreateFolderButton
|
||||||
leftIcon={<FolderPlusIcon />}
|
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
isLoading={isCreatingFolder || isFolderLoading}
|
isLoading={isCreatingFolder || isFolderLoading}
|
||||||
>
|
/>
|
||||||
Create a folder
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
<Wrap spacing={4}>
|
<Wrap spacing={4}>
|
||||||
<CreateBotButton
|
<CreateBotButton
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 { useUser } from 'contexts/UserContext'
|
||||||
|
import React from 'react'
|
||||||
|
import { isFreePlan } from 'services/user'
|
||||||
|
|
||||||
|
type Props = { isLoading: boolean; onClick: () => void }
|
||||||
|
|
||||||
|
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||||
|
const { user } = useUser()
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isFreePlan(user)) return onOpen()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
leftIcon={<FolderPlusIcon />}
|
||||||
|
onClick={handleClick}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<HStack>
|
||||||
|
<Text>Create a folder</Text>
|
||||||
|
{isFreePlan(user) && <Tag colorScheme="orange">Pro</Tag>}
|
||||||
|
</HStack>
|
||||||
|
{user && (
|
||||||
|
<UpgradeModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
user={user}
|
||||||
|
type={LimitReached.FOLDER}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
28
apps/builder/components/shared/modals/UpgradeModal./Card.tsx
Normal file
28
apps/builder/components/shared/modals/UpgradeModal./Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
Stack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { PricingCard } from './PricingCard'
|
||||||
|
import { ActionButton } from './ActionButton'
|
||||||
|
import { User } from 'db'
|
||||||
|
import { pay } from 'services/stripe'
|
||||||
|
|
||||||
|
export enum LimitReached {
|
||||||
|
BRAND = 'Remove branding',
|
||||||
|
CUSTOM_DOMAIN = 'Custom domain',
|
||||||
|
FOLDER = 'Create folders',
|
||||||
|
ANALYTICS = 'Unlock analytics',
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpgradeModalProps = {
|
||||||
|
user: User
|
||||||
|
type: LimitReached
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpgradeModal = ({
|
||||||
|
type,
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
isOpen,
|
||||||
|
}: UpgradeModalProps) => {
|
||||||
|
const [payLoading, setPayLoading] = useState(false)
|
||||||
|
const [userLanguage, setUserLanguage] = useState<string>('en')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserLanguage(navigator.language.toLowerCase())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
let limitLabel
|
||||||
|
switch (type) {
|
||||||
|
case LimitReached.BRAND: {
|
||||||
|
limitLabel = "You can't hide Typebot brand on the Basic plan"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case LimitReached.CUSTOM_DOMAIN: {
|
||||||
|
limitLabel = "You can't add your domain with the Basic plan"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case LimitReached.FOLDER: {
|
||||||
|
limitLabel = "You can't create folders with the basic plan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePayClick = async () => {
|
||||||
|
if (!user) return
|
||||||
|
setPayLoading(true)
|
||||||
|
await pay(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Upgrade to Pro plan</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody as={Stack} spacing={6} alignItems="center">
|
||||||
|
{limitLabel && (
|
||||||
|
<Alert status="warning" rounded="md">
|
||||||
|
<AlertIcon />
|
||||||
|
{limitLabel}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<PricingCard
|
||||||
|
data={{
|
||||||
|
price: userLanguage.includes('fr') ? '25€' : '$30',
|
||||||
|
name: 'Pro plan',
|
||||||
|
features: [
|
||||||
|
'Branding removed',
|
||||||
|
'View incomplete submissions',
|
||||||
|
'In-depth drop off analytics',
|
||||||
|
'Custom domains',
|
||||||
|
'Organize typebots in folders',
|
||||||
|
'Unlimited uploads',
|
||||||
|
'Custom Google Analytics events',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
button={
|
||||||
|
<ActionButton onClick={handlePayClick} isLoading={payLoading}>
|
||||||
|
Upgrade now
|
||||||
|
</ActionButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { UpgradeModal } from './UpgradeModal'
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"@giphy/react-components": "^5.4.0",
|
"@giphy/react-components": "^5.4.0",
|
||||||
"@googleapis/drive": "^2.1.0",
|
"@googleapis/drive": "^2.1.0",
|
||||||
"@next-auth/prisma-adapter": "1.0.1",
|
"@next-auth/prisma-adapter": "1.0.1",
|
||||||
|
"@stripe/stripe-js": "^1.22.0",
|
||||||
"@udecode/plate-basic-marks": "^10.0.0",
|
"@udecode/plate-basic-marks": "^10.0.0",
|
||||||
"@udecode/plate-common": "^7.0.2",
|
"@udecode/plate-common": "^7.0.2",
|
||||||
"@udecode/plate-core": "^10.0.0",
|
"@udecode/plate-core": "^10.0.0",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const createCheckoutSession = async (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
res.status(201).json(session)
|
res.status(201).send({ sessionId: session.id })
|
||||||
}
|
}
|
||||||
return methodNotAllowed(res)
|
return methodNotAllowed(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import test, { expect, Page } from '@playwright/test'
|
import test, { expect, Page } from '@playwright/test'
|
||||||
|
import path from 'path'
|
||||||
import { generate } from 'short-uuid'
|
import { generate } from 'short-uuid'
|
||||||
import { createFolders, createTypebots } from '../services/database'
|
import { createFolders, createTypebots } from '../services/database'
|
||||||
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
import { deleteButtonInConfirmDialog } from '../services/selectorUtils'
|
||||||
@@ -72,6 +73,20 @@ test.describe('Dashboard page', () => {
|
|||||||
await page.click('a:has-text("Back")')
|
await page.click('a:has-text("Back")')
|
||||||
await expect(typebotButton).toBeVisible()
|
await expect(typebotButton).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Free user', () => {
|
||||||
|
test.use({
|
||||||
|
storageState: path.join(__dirname, '../freeUser.json'),
|
||||||
|
})
|
||||||
|
test("create folder shouldn't be available", async ({ page }) => {
|
||||||
|
await page.goto('/typebots')
|
||||||
|
await page.click('text=Create a folder')
|
||||||
|
await expect(
|
||||||
|
page.locator('text="You can\'t create folders with the basic plan"')
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const waitForNextApiCall = (page: Page, path?: string) =>
|
const waitForNextApiCall = (page: Page, path?: string) =>
|
||||||
|
|||||||
18
apps/builder/services/stripe.ts
Normal file
18
apps/builder/services/stripe.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { User } from 'db'
|
||||||
|
import { loadStripe } from '@stripe/stripe-js'
|
||||||
|
import { sendRequest } from 'utils'
|
||||||
|
|
||||||
|
export const pay = async (user: User) => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
|
||||||
|
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
|
||||||
|
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
|
||||||
|
const { data, error } = await sendRequest<{ sessionId: string }>({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/stripe/checkout',
|
||||||
|
body: { email: user.email },
|
||||||
|
})
|
||||||
|
if (error || !data) return
|
||||||
|
return stripe?.redirectToCheckout({
|
||||||
|
sessionId: data?.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { User } from 'db'
|
import { Plan, User } from 'db'
|
||||||
import { sendRequest } from 'utils'
|
import { isNotDefined, sendRequest } from 'utils'
|
||||||
|
|
||||||
export const updateUser = async (id: string, user: User) =>
|
export const updateUser = async (id: string, user: User) =>
|
||||||
sendRequest({
|
sendRequest({
|
||||||
@@ -7,3 +7,6 @@ export const updateUser = async (id: string, user: User) =>
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: user,
|
body: user,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const isFreePlan = (user?: User) =>
|
||||||
|
isNotDefined(user) || user?.plan === Plan.FREE
|
||||||
|
|||||||
@@ -2027,6 +2027,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz#8863915676f837d9dad7b76f50cb500c1e9422e9"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz#8863915676f837d9dad7b76f50cb500c1e9422e9"
|
||||||
integrity sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q==
|
integrity sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q==
|
||||||
|
|
||||||
|
"@stripe/stripe-js@^1.22.0":
|
||||||
|
version "1.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.22.0.tgz#9d3d2f0a1ce81f185ec477fd7cc67544b2b2a00c"
|
||||||
|
integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg==
|
||||||
|
|
||||||
"@szmarczak/http-timer@^5.0.1":
|
"@szmarczak/http-timer@^5.0.1":
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-5.0.1.tgz#c7c1bf1141cdd4751b0399c8fc7b8b664cd5be3a"
|
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-5.0.1.tgz#c7c1bf1141cdd4751b0399c8fc7b8b664cd5be3a"
|
||||||
|
|||||||
Reference in New Issue
Block a user