♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@@ -0,0 +1,121 @@
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, isNotDefined } from 'utils'
import { dequal } from 'dequal'
import { User } from 'db'
import { setUser as setSentryUser } from '@sentry/nextjs'
import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery'
const userContext = createContext<{
user?: User
isLoading: boolean
isSaving: boolean
hasUnsavedChanges: boolean
isOAuthProvider: boolean
currentWorkspaceId?: string
updateUser: (newUser: Partial<User>) => void
saveUser: (newUser?: Partial<User>) => Promise<void>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const UserProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter()
const { data: session, status } = useSession()
const [user, setUser] = useState<User | undefined>()
const { showToast } = useToast()
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
const [isSaving, setIsSaving] = useState(false)
const isOAuthProvider = useMemo(
() => (session?.providerType as boolean | undefined) ?? false,
[session?.providerType]
)
const hasUnsavedChanges = useMemo(
() => !dequal(session?.user, user),
[session?.user, user]
)
useEffect(() => {
if (isDefined(user) || isNotDefined(session)) return
setCurrentWorkspaceId(
localStorage.getItem('currentWorkspaceId') ?? undefined
)
const parsedUser = session.user as User
setUser(parsedUser)
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
useEffect(() => {
if (!router.isReady) return
if (status === 'loading') return
if (!user && status === 'unauthenticated' && !isSigningIn())
router.replace({
pathname: '/signin',
query:
router.pathname !== '/typebots'
? {
redirectPath: router.asPath,
}
: undefined,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, router])
const isSigningIn = () => ['/signin', '/register'].includes(router.pathname)
const updateUser = (newUser: Partial<User>) => {
if (isNotDefined(user)) return
setUser({ ...user, ...newUser })
}
const saveUser = async (newUser?: Partial<User>) => {
if (isNotDefined(user)) return
setIsSaving(true)
if (newUser) updateUser(newUser)
const { error } = await updateUserQuery(user.id, { ...user, ...newUser })
if (error) showToast({ title: error.name, description: error.message })
await refreshUser()
setIsSaving(false)
}
return (
<userContext.Provider
value={{
user,
isSaving,
isLoading: status === 'loading',
hasUnsavedChanges,
isOAuthProvider,
currentWorkspaceId,
updateUser,
saveUser,
}}
>
{children}
</userContext.Provider>
)
}
export const refreshUser = async () => {
await fetch('/api/auth/session?update')
reloadSession()
}
const reloadSession = () => {
const event = new Event('visibilitychange')
document.dispatchEvent(event)
}
export const useUser = () => useContext(userContext)

View File

@@ -0,0 +1,49 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { userId } from 'utils/playwright/databaseSetup'
test.describe.configure({ mode: 'parallel' })
test('should display user info properly', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
const saveButton = page.locator('button:has-text("Save")')
await expect(saveButton).toBeHidden()
expect(
page.locator('input[type="email"]').getAttribute('disabled')
).toBeDefined()
await page.fill('#name', 'John Doe')
expect(saveButton).toBeVisible()
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img >> nth=1')).toHaveAttribute(
'src',
new RegExp(
`${process.env.S3_ENDPOINT}${
process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''
}/${process.env.S3_BUCKET}/public/users/${userId}/avatar`,
'gm'
)
)
await page.click('text="Preferences"')
await expect(page.locator('text=Trackpad')).toBeVisible()
})
test('should be able to create and delete api tokens', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await expect(page.locator('text=Github')).toBeVisible()
await page.click('text="Create"')
await expect(page.locator('button >> text="Create token"')).toBeDisabled()
await page.fill('[placeholder="I.e. Zapier, Github, Make.com"]', 'CLI')
await expect(page.locator('button >> text="Create token"')).toBeEnabled()
await page.click('button >> text="Create token"')
await expect(page.locator('text=Please copy your token')).toBeVisible()
await expect(page.locator('button >> text="Copy"')).toBeVisible()
await page.click('button >> text="Done"')
await expect(page.locator('text=CLI')).toBeVisible()
await page.click('text="Delete" >> nth=2')
await expect(page.locator('strong >> text="Github"')).toBeVisible()
await page.click('button >> text="Delete" >> nth=-1')
await expect(page.locator('button >> text="Delete" >> nth=-1')).toBeEnabled()
await expect(page.locator('text="Github"')).toBeHidden()
})

View File

@@ -0,0 +1,130 @@
import {
TableContainer,
Table,
Thead,
Tr,
Th,
Tbody,
Td,
Button,
Text,
Heading,
Checkbox,
Skeleton,
Stack,
Flex,
useDisclosure,
} from '@chakra-ui/react'
import { ConfirmModal } from '@/components/ConfirmModal'
import { useToast } from '@/hooks/useToast'
import { User } from 'db'
import React, { useState } from 'react'
import { byId, isDefined } from 'utils'
import { CreateTokenModal } from './CreateTokenModal'
import { useApiTokens } from '../../../hooks/useApiTokens'
import { ApiTokenFromServer } from '../../../types'
import { timeSince } from '@/utils/helpers'
import { deleteApiTokenQuery } from '../../../queries/deleteApiTokenQuery'
type Props = { user: User }
export const ApiTokensList = ({ user }: Props) => {
const { showToast } = useToast()
const { apiTokens, isLoading, mutate } = useApiTokens({
userId: user.id,
onError: (e) =>
showToast({ title: 'Failed to fetch tokens', description: e.message }),
})
const {
isOpen: isCreateOpen,
onOpen: onCreateOpen,
onClose: onCreateClose,
} = useDisclosure()
const [deletingId, setDeletingId] = useState<string>()
const refreshListWithNewToken = (token: ApiTokenFromServer) => {
if (!apiTokens) return
mutate({ apiTokens: [token, ...apiTokens] })
}
const deleteToken = async (tokenId?: string) => {
if (!apiTokens || !tokenId) return
const { error } = await deleteApiTokenQuery({ userId: user.id, tokenId })
if (!error) mutate({ apiTokens: apiTokens.filter((t) => t.id !== tokenId) })
}
return (
<Stack spacing={4}>
<Heading fontSize="2xl">API tokens</Heading>
<Text>
These tokens allow other apps to control your whole account and
typebots. Be careful!
</Text>
<Flex justifyContent="flex-end">
<Button onClick={onCreateOpen}>Create</Button>
<CreateTokenModal
userId={user.id}
isOpen={isCreateOpen}
onNewToken={refreshListWithNewToken}
onClose={onCreateClose}
/>
</Flex>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th w="130px">Created</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{apiTokens?.map((token) => (
<Tr key={token.id}>
<Td>{token.name}</Td>
<Td>{timeSince(token.createdAt)} ago</Td>
<Td>
<Button
size="xs"
colorScheme="red"
variant="outline"
onClick={() => setDeletingId(token.id)}
>
Delete
</Button>
</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>
<ConfirmModal
isOpen={isDefined(deletingId)}
onConfirm={() => deleteToken(deletingId)}
onClose={() => setDeletingId(undefined)}
message={
<Text>
The token <strong>{apiTokens?.find(byId(deletingId))?.name}</strong>{' '}
will be permanently deleted, are you sure you want to continue?
</Text>
}
confirmButtonLabel="Delete"
/>
</Stack>
)
}

View File

@@ -0,0 +1,101 @@
import { CopyButton } from '@/components/CopyButton'
import { createApiTokenQuery } from '../../../queries/createApiTokenQuery'
import { ApiTokenFromServer } from '../../../types'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Input,
ModalFooter,
Button,
Text,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import React, { FormEvent, useState } from 'react'
type Props = {
userId: string
isOpen: boolean
onNewToken: (token: ApiTokenFromServer) => void
onClose: () => void
}
export const CreateTokenModal = ({
userId,
isOpen,
onClose,
onNewToken,
}: Props) => {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [newTokenValue, setNewTokenValue] = useState<string>()
const createToken = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
const { data } = await createApiTokenQuery(userId, { name })
if (data?.apiToken) {
setNewTokenValue(data.apiToken.token)
onNewToken(data.apiToken)
}
setIsSubmitting(false)
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{newTokenValue ? 'Token Created' : 'Create Token'}
</ModalHeader>
<ModalCloseButton />
{newTokenValue ? (
<ModalBody as={Stack} spacing="4">
<Text>
Please copy your token and store it in a safe place.{' '}
<strong>For security reasons we cannot show it again.</strong>
</Text>
<InputGroup size="md">
<Input readOnly pr="4.5rem" value={newTokenValue} />
<InputRightElement width="4.5rem">
<CopyButton h="1.75rem" size="sm" textToCopy={newTokenValue} />
</InputRightElement>
</InputGroup>
</ModalBody>
) : (
<ModalBody as="form" onSubmit={createToken}>
<Text mb="4">
Enter a unique name for your token to differentiate it from other
tokens.
</Text>
<Input
placeholder="I.e. Zapier, Github, Make.com"
onChange={(e) => setName(e.target.value)}
/>
</ModalBody>
)}
<ModalFooter>
{newTokenValue ? (
<Button onClick={onClose} colorScheme="blue">
Done
</Button>
) : (
<Button
colorScheme="blue"
isDisabled={name.length === 0}
isLoading={isSubmitting}
onClick={createToken}
>
Create token
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { ApiTokensList } from './ApiTokensList'

View File

@@ -0,0 +1,110 @@
import {
Stack,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
} from '@chakra-ui/react'
import { UploadIcon } from '@/components/icons'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
import { useUser } from '@/features/account'
export const MyAccountForm = () => {
const {
user,
updateUser,
saveUser,
hasUnsavedChanges,
isSaving,
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const handleFileUploaded = async (url: string) => {
setReloadParam(Date.now().toString())
updateUser({ image: url })
}
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ name: e.target.value })
}
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ email: e.target.value })
}
return (
<Stack spacing="6" w="full" overflowY="scroll">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`users/${user?.id}/avatar`}
leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
Change photo
</UploadButton>
<Text color="gray.500" fontSize="sm">
.jpg or.png, max 1MB
</Text>
</Stack>
</HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel
htmlFor="email"
color={isOAuthProvider ? 'gray.500' : 'current'}
>
Email address
</FormLabel>
<Input
id="email"
type="email"
isDisabled
value={user?.email ?? ''}
onChange={handleEmailChange}
/>
</FormControl>
</Tooltip>
)}
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
{user && <ApiTokensList user={user} />}
</Stack>
)
}

View File

@@ -0,0 +1 @@
export { MyAccountForm } from './MyAccountForm'

View File

@@ -0,0 +1,30 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
import { ApiTokenFromServer } from '../types'
type ServerResponse = {
apiTokens: ApiTokenFromServer[]
}
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -0,0 +1,3 @@
export { UserProvider, useUser } from './UserProvider'
export type { ApiTokenFromServer } from './types'
export { MyAccountForm } from './components/MyAccountForm'

View File

@@ -0,0 +1,14 @@
import { sendRequest } from 'utils'
import { ApiTokenFromServer } from '../types'
export const createApiTokenQuery = (
userId: string,
{ name }: { name: string }
) =>
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
url: `/api/users/${userId}/api-tokens`,
method: 'POST',
body: {
name,
},
})

View File

@@ -0,0 +1,14 @@
import { ApiToken } from 'db'
import { sendRequest } from 'utils'
export const deleteApiTokenQuery = ({
userId,
tokenId,
}: {
userId: string
tokenId: string
}) =>
sendRequest<{ apiToken: ApiToken }>({
url: `/api/users/${userId}/api-tokens/${tokenId}`,
method: 'DELETE',
})

View File

@@ -0,0 +1,9 @@
import { User } from 'db'
import { sendRequest } from 'utils'
export const updateUserQuery = async (id: string, user: User) =>
sendRequest({
url: `/api/users/${id}`,
method: 'PUT',
body: user,
})

View File

@@ -0,0 +1 @@
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }

View File

@@ -0,0 +1,32 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import {
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { starterWorkspaceId } from 'utils/playwright/databaseSetup'
test('analytics are not available for non-pro workspaces', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/results/submissionHeader.json'),
{
id: typebotId,
workspaceId: starterWorkspaceId,
}
)
await injectFakeResults({ typebotId, count: 10 })
await page.goto(`/typebots/${typebotId}/results/analytics`)
const firstDropoffBox = page.locator('text="%" >> nth=0')
await firstDropoffBox.hover()
await expect(
page.locator('text="Unlock Drop-off rate by upgrading to Pro plan"')
).toBeVisible()
await firstDropoffBox.click()
await expect(
page.locator(
'text="You need to upgrade your plan in order to unlock in-depth analytics"'
)
).toBeVisible()
})

View File

@@ -0,0 +1,63 @@
import { Flex, Spinner, useDisclosure } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { useTypebot } from '@/features/editor'
import { Stats } from 'models'
import React from 'react'
import { useAnswersCount } from '../hooks/useAnswersCount'
import {
Graph,
GraphProvider,
GroupsCoordinatesProvider,
} from '@/features/graph'
import { ChangePlanModal, LimitReached } from '@/features/billing'
import { StatsCards } from './StatsCards'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot()
const { showToast } = useToast()
const { answersCounts } = useAnswersCount({
typebotId: publishedTypebot && typebot?.id,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
return (
<Flex
w="full"
pos="relative"
bgColor="gray.50"
h="full"
justifyContent="center"
>
{publishedTypebot && answersCounts && stats ? (
<GraphProvider isReadOnly>
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
answersCounts={[
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts?.slice(1),
]}
/>
</GroupsCoordinatesProvider>
</GraphProvider>
) : (
<Flex
justify="center"
align="center"
boxSize="full"
bgColor="rgba(255, 255, 255, 0.5)"
>
<Spinner color="gray" />
</Flex>
)}
<ChangePlanModal
onClose={onClose}
isOpen={isOpen}
type={LimitReached.ANALYTICS}
/>
<StatsCards stats={stats} pos="absolute" top={10} />
</Flex>
)
}

View File

@@ -0,0 +1,46 @@
import {
GridProps,
SimpleGrid,
Skeleton,
Stat,
StatLabel,
StatNumber,
} from '@chakra-ui/react'
import { Stats } from 'models'
import React from 'react'
export const StatsCards = ({
stats,
...props
}: { stats?: Stats } & GridProps) => {
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing="6" {...props}>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Views</StatLabel>
{stats ? (
<StatNumber>{stats.totalViews}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Starts</StatLabel>
{stats ? (
<StatNumber>{stats.totalStarts}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Completion rate</StatLabel>
{stats ? (
<StatNumber>
{Math.round((stats.totalCompleted / stats.totalStarts) * 100)}%
</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
</SimpleGrid>
)
}

View File

@@ -0,0 +1,25 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { AnswersCount } from '../types'
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/results/answers/count` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -0,0 +1,2 @@
export { AnalyticsGraphContainer } from './components/AnalyticsGraphContainer'
export type { AnswersCount } from './types'

View File

@@ -0,0 +1 @@
export type AnswersCount = { groupId: string; totalAnswers: number }

View File

@@ -0,0 +1,14 @@
import { setUser } from '@sentry/nextjs'
import { User } from 'db'
import { NextApiRequest } from 'next'
import { getSession } from 'next-auth/react'
export const getAuthenticatedUser = async (
req: NextApiRequest
): Promise<User | undefined> => {
const session = await getSession({ req })
if (!session?.user || !('id' in session.user)) return
const user = session.user as User
setUser({ id: user.id, email: user.email ?? undefined })
return session?.user as User
}

View File

@@ -0,0 +1,31 @@
import {
FlexProps,
Flex,
Box,
Divider,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import React from 'react'
export const DividerWithText = (props: FlexProps) => {
const { children, ...flexProps } = props
return (
<Flex align="center" color="gray.300" {...flexProps}>
<Box flex="1">
<Divider borderColor="currentcolor" />
</Box>
<Text
as="span"
px="3"
color={useColorModeValue('gray.600', 'gray.400')}
fontWeight="medium"
>
{children}
</Text>
<Box flex="1">
<Divider borderColor="currentcolor" />
</Box>
</Flex>
)
}

View File

@@ -0,0 +1,122 @@
import {
Button,
HTMLChakraProps,
Input,
Stack,
HStack,
Text,
Spinner,
} from '@chakra-ui/react'
import React, { ChangeEvent, FormEvent, useEffect } from 'react'
import { useState } from 'react'
import {
ClientSafeProvider,
getProviders,
LiteralUnion,
signIn,
useSession,
} from 'next-auth/react'
import { DividerWithText } from './DividerWithText'
import { SocialLoginButtons } from './SocialLoginButtons'
import { useRouter } from 'next/router'
import { BuiltInProviderType } from 'next-auth/providers'
import { useToast } from '@/hooks/useToast'
import { TextLink } from '@/components/TextLink'
type Props = {
defaultEmail?: string
}
export const SignInForm = ({
defaultEmail,
}: Props & HTMLChakraProps<'form'>) => {
const router = useRouter()
const { status } = useSession()
const [authLoading, setAuthLoading] = useState(false)
const [isLoadingProviders, setIsLoadingProviders] = useState(true)
const [emailValue, setEmailValue] = useState(defaultEmail ?? '')
const { showToast } = useToast()
const [providers, setProviders] =
useState<
Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider>
>()
const hasNoAuthProvider =
!isLoadingProviders && Object.keys(providers ?? {}).length === 0
useEffect(() => {
if (status === 'authenticated')
router.replace({ pathname: '/typebots', query: router.query })
;(async () => {
const providers = await getProviders()
setProviders(providers ?? undefined)
setIsLoadingProviders(false)
})()
}, [status, router])
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) =>
setEmailValue(e.target.value)
const handleEmailSubmit = async (e: FormEvent) => {
e.preventDefault()
setAuthLoading(true)
const response = await signIn('email', {
email: emailValue,
redirect: false,
})
response?.error
? showToast({
title: 'Unauthorized',
description: 'Sign ups are disabled.',
})
: showToast({
status: 'success',
title: 'Success!',
description: 'Check your inbox to sign in',
})
setAuthLoading(false)
}
if (isLoadingProviders) return <Spinner />
if (hasNoAuthProvider)
return (
<Text>
You need to{' '}
<TextLink
href="https://docs.typebot.io/self-hosting/configuration"
isExternal
>
configure at least one auth provider
</TextLink>{' '}
(Email, Google, GitHub, Facebook or Azure AD).
</Text>
)
return (
<Stack spacing="4" w="330px">
<SocialLoginButtons providers={providers} />
{providers?.email && (
<>
<DividerWithText mt="6">Or with your email</DividerWithText>
<HStack as="form" onSubmit={handleEmailSubmit}>
<Input
name="email"
type="email"
autoComplete="email"
placeholder="email@company.com"
required
value={emailValue}
onChange={handleEmailChange}
/>
<Button
type="submit"
isLoading={
['loading', 'authenticated'].includes(status) || authLoading
}
>
Submit
</Button>
</HStack>
</>
)}
</Stack>
)
}

View File

@@ -0,0 +1,38 @@
import { Seo } from '@/components/Seo'
import { TextLink } from '@/components/TextLink'
import { VStack, Heading, Text } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { SignInForm } from './SignInForm'
type Props = {
type: 'signin' | 'signup'
defaultEmail?: string
}
export const SignInPage = ({ type }: Props) => {
const { query } = useRouter()
return (
<VStack spacing={4} h="100vh" justifyContent="center">
<Seo title={type === 'signin' ? 'Sign In' : 'Register'} />
<Heading
onClick={() => {
throw new Error('Sentry is working')
}}
>
{type === 'signin' ? 'Sign In' : 'Create an account'}
</Heading>
{type === 'signin' ? (
<Text>
Don't have an account?{' '}
<TextLink href="/register">Sign up for free</TextLink>
</Text>
) : (
<Text>
Already have an account? <TextLink href="/signin">Sign in</TextLink>
</Text>
)}
<SignInForm defaultEmail={query.g?.toString()} />
</VStack>
)
}

View File

@@ -0,0 +1,110 @@
import { Stack, Button } from '@chakra-ui/react'
import { GithubIcon } from '@/components/icons'
import {
ClientSafeProvider,
LiteralUnion,
signIn,
useSession,
} from 'next-auth/react'
import { useRouter } from 'next/router'
import React from 'react'
import { stringify } from 'qs'
import { BuiltInProviderType } from 'next-auth/providers'
import { GoogleLogo } from '@/components/GoogleLogo'
import { AzureAdLogo, FacebookLogo, GitlabLogo } from './logos'
type Props = {
providers:
| Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider>
| undefined
}
export const SocialLoginButtons = ({ providers }: Props) => {
const { query } = useRouter()
const { status } = useSession()
const handleGitHubClick = async () =>
signIn('github', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleGoogleClick = async () =>
signIn('google', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleFacebookClick = async () =>
signIn('facebook', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleGitlabClick = async () =>
signIn('gitlab', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleAzureAdClick = async () =>
signIn('azure-ad', {
callbackUrl: `/typebots?${stringify(query)}`,
})
return (
<Stack>
{providers?.github && (
<Button
leftIcon={<GithubIcon />}
onClick={handleGitHubClick}
data-testid="github"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with GitHub
</Button>
)}
{providers?.google && (
<Button
leftIcon={<GoogleLogo />}
onClick={handleGoogleClick}
data-testid="google"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with Google
</Button>
)}
{providers?.facebook && (
<Button
leftIcon={<FacebookLogo />}
onClick={handleFacebookClick}
data-testid="facebook"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with Facebook
</Button>
)}
{providers?.gitlab && (
<Button
leftIcon={<GitlabLogo />}
onClick={handleGitlabClick}
data-testid="gitlab"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with {providers.gitlab.name}
</Button>
)}
{providers?.['azure-ad'] && (
<Button
leftIcon={<AzureAdLogo />}
onClick={handleAzureAdClick}
data-testid="azure-ad"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with {providers['azure-ad'].name}
</Button>
)}
</Stack>
)
}

View File

@@ -0,0 +1,31 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const AzureAdLogo = (props: IconProps) => {
return (
<Icon
id="svg1035"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 374.5 377.3"
{...props}
>
<g id="layer1" transform="translate(-39.022 -78.115)">
<g id="g1016" transform="translate(-63.947 -88.179)">
<path
id="path1008"
fill='#00bef2'
d="M290 166.3c.4 0 .8.5 1.4 1.4.5.8 42.6 51.3 93.6 112.2 51 60.9 92.6 111 92.4 111.3-.1.3-40.7 33.6-90.2 73.9s-91.6 74.6-93.5 76.2c-3.3 2.7-3.5 2.8-4.7 1.6-.7-.7-42.9-35.2-93.8-76.7S102.8 390.5 103 390c.2-.5 42-50.4 93.1-111s92.9-110.7 93.1-111.5c.2-.8.5-1.2.8-1.2z"
/>
<path
id="path923"
fill="#fff"
stroke="#fff"
strokeWidth="1.2357"
strokeLinecap="round"
strokeLinejoin="round"
d="M283.1 483.6c-5.8-2.1-12.8-8.1-15.7-13.7-3.6-6.9-3.3-17.7.7-26.3 3.1-6.4 3.1-6.6 1.1-8.1-1.1-.8-14.4-8.2-29.4-16.3-15-8.1-28.1-15.2-29-15.7-1.2-.7-3.2 0-6.8 2.3-11.7 7.4-23.9 6.6-33.5-2.3-6.9-6.4-8.9-10.9-8.9-20.1 0-8.9 1.8-13.5 7.5-19.2 7.7-7.7 18-10.3 27.9-7 5.4 1.8 5.5 1.8 8.9-.8 4-3 36.1-32.3 51.6-47l10.7-10.2-3.2-6.7c-6.5-13.5-3.2-28.5 8.2-37.5 6.2-4.9 10.8-6.4 19.7-6.4 20.8 0 35.3 21.8 27.5 41.3-2.1 5.4-2.1 5.5-.1 8.8 1.7 2.9 30.6 37.8 45.9 55.6 2.7 3.1 5.7 5.6 6.7 5.6s4.4-1 7.6-2.2c14.9-5.9 30.6.7 36.8 15.5 4 9.5.5 22.3-8 30-6 5.4-10.4 7.1-18.4 7.1-5.6 0-7.7-.6-13.6-3.8-4.4-2.4-7.8-3.6-9.2-3.2-2.4.6-39.3 25.9-47.5 32.5-5 4.1-5.4 5.6-2.8 11.7 2.5 6 2.2 15.4-.6 21.3-3.1 6.5-10.8 13-17.5 15-6.8 1.9-10.9 1.9-16.6-.2zm1.7-110.2v-57l-3.2-4.4c-1.8-2.4-3.5-4.4-3.8-4.4-1.3 0-65.9 58.7-65.9 59.9 0 .3 1 3.3 2.2 6.5 1.2 3.3 2.1 8 2 10.7-.1 2.7-.1 5.7-.1 6.7.1 2.3 21.7 16.1 54.1 34.8 8.9 5.2 12 6.5 13.1 5.6 1.3-1.1 1.6-12.2 1.6-58.4zm27.4 50.4c42.8-26.9 50.8-32.3 51.3-34.3.3-1.2.7-5.9.8-10.6l.3-8.4-21.8-25.9c-23.4-27.7-32-37.1-34-37.1-.7 0-4.2 2-7.8 4.4l-6.6 4.4.3 56.9c.3 51 .7 59.6 2.6 59.6.2.1 7-4 14.9-9z"
/>
</g>
</g>
</Icon>
)
}

View File

@@ -0,0 +1,11 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const FacebookLogo = (props: IconProps) => (
<Icon viewBox="0 0 14222 14222" {...props}>
<circle cx="7111" cy="7112" r="7111" fill="#1977f3" />
<path
d="M9879 9168l315-2056H8222V5778c0-562 275-1111 1159-1111h897V2917s-814-139-1592-139c-1624 0-2686 984-2686 2767v1567H4194v2056h1806v4969c362 57 733 86 1111 86s749-30 1111-86V9168z"
fill="#fff"
/>
</Icon>
)

View File

@@ -0,0 +1,13 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GitlabLogo = (props: IconProps) => (
<Icon viewBox="0 0 256 236" {...props}>
<path d="M128.075 236.075l47.104-144.97H80.97l47.104 144.97z" fill="#E24329" />
<path d="M128.075 236.074L80.97 91.104H14.956l113.119 144.97z" fill="#FC6D26" />
<path d="M14.956 91.104L.642 135.16a9.752 9.752 0 0 0 3.542 10.903l123.891 90.012-113.12-144.97z" fill="#FCA326" />
<path d="M14.956 91.105H80.97L52.601 3.79c-1.46-4.493-7.816-4.492-9.275 0l-28.37 87.315z" fill="#E24329" />
<path d="M128.075 236.074l47.104-144.97h66.015l-113.12 144.97z" fill="#FC6D26" />
<path d="M241.194 91.104l14.314 44.056a9.752 9.752 0 0 1-3.543 10.903l-123.89 90.012 113.119-144.97z" fill="#FCA326" />
<path d="M241.194 91.105h-66.015l28.37-87.315c1.46-4.493 7.816-4.492 9.275 0l28.37 87.315z" fill="#E24329" />
</Icon>
)

View File

@@ -0,0 +1,3 @@
export { AzureAdLogo } from './AzureAdLogo'
export { GitlabLogo } from './GitlabLogo'
export { FacebookLogo } from './FacebookLogo'

View File

@@ -0,0 +1,15 @@
import { User } from 'db'
export const mockedUser: User = {
id: 'userId',
name: 'John Doe',
email: 'user@email.com',
company: null,
createdAt: new Date(),
emailVerified: null,
graphNavigation: 'TRACKPAD',
image: 'https://avatars.githubusercontent.com/u/16015833?v=4',
lastActivityAt: new Date(),
onboardingCategories: [],
updatedAt: new Date(),
}

View File

@@ -0,0 +1,3 @@
export { SignInPage } from './components/SignInPage'
export { getAuthenticatedUser } from './api/getAuthenticatedUser'
export { mockedUser } from './constants'

View File

@@ -0,0 +1,259 @@
import {
addSubscriptionToWorkspace,
createClaimableCustomPlan,
} from '@/test/utils/databaseActions'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { Plan } from 'db'
import {
createTypebots,
createWorkspaces,
deleteWorkspaces,
injectFakeResults,
} from 'utils/playwright/databaseActions'
const usageWorkspaceId = cuid()
const usageTypebotId = cuid()
const planChangeWorkspaceId = cuid()
const enterpriseWorkspaceId = cuid()
test.beforeAll(async () => {
await createWorkspaces([
{
id: usageWorkspaceId,
name: 'Usage Workspace',
plan: Plan.STARTER,
},
{
id: planChangeWorkspaceId,
name: 'Plan Change Workspace',
},
{
id: enterpriseWorkspaceId,
name: 'Enterprise Workspace',
},
])
await createTypebots([{ id: usageTypebotId, workspaceId: usageWorkspaceId }])
})
test.afterAll(async () => {
await deleteWorkspaces([
usageWorkspaceId,
planChangeWorkspaceId,
enterpriseWorkspaceId,
])
})
test('should display valid usage', async ({ page }) => {
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.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/5)' })
).toBeVisible()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text="Custom workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 100,000"')).toBeVisible()
await expect(page.locator('text="/ 50 GB"')).toBeVisible()
await expect(page.getByText('Upgrade to Starter')).toBeHidden()
await expect(page.getByText('Upgrade to Pro')).toBeHidden()
await expect(page.getByText('Need custom limits?')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/20)' })
).toBeVisible()
await page.click('text=Custom workspace', { force: true })
await page.click('text=Custom 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 expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/1)' })
).toBeVisible()
await page.click('text=Free workspace', { force: true })
await injectFakeResults({
count: 10,
typebotId: usageTypebotId,
fakeStorage: 1100 * 1024 * 1024,
})
await page.click('text=Free workspace')
await page.click('text="Usage 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="10" >> nth=0')).toBeVisible()
await expect(page.locator('[role="progressbar"] >> nth=0')).toHaveAttribute(
'aria-valuenow',
'1'
)
await expect(page.locator('text="1.07 GB"')).toBeVisible()
await expect(page.locator('[role="progressbar"] >> nth=1')).toHaveAttribute(
'aria-valuenow',
'54'
)
await injectFakeResults({
typebotId: usageTypebotId,
count: 1090,
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 }) => {
test.setTimeout(80000)
// Upgrade to STARTER
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Plan Change 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(
planChangeWorkspaceId,
[
{
price: process.env.STRIPE_STARTER_PRICE_ID,
quantity: 1,
},
],
{ plan: Plan.STARTER, additionalChatsIndex: 0, additionalStorageIndex: 0 }
)
// 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="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('button >> text="2,000"')).toBeVisible()
await expect(page.locator('button >> text="2"')).toBeVisible()
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=Update')
await expect(
page.locator(
'text="Workspace STARTER plan successfully updated 🎉" >> nth=0'
)
).toBeVisible()
await page.click('text="Members"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="$73"')).toBeVisible()
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()
// 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="current-subscription"]')).toHaveText(
'Current workspace subscription: ProCancel my subscription'
)
await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: Free'
)
// Upgrade again to PRO
await page.getByRole('button', { name: 'Upgrade' }).nth(1).click()
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible({ timeout: 20 * 1000 })
})
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="Invoices"')).toBeHidden()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text=Plan Change Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(3)
await expect(page.locator('text="$39.00"')).toBeVisible()
})
test('custom plans should work', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Enterprise Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.getByTestId('current-subscription')).toHaveText(
'Current workspace subscription: Free'
)
await createClaimableCustomPlan({
currency: 'usd',
price: 239,
workspaceId: enterpriseWorkspaceId,
chatsLimit: 100000,
storageLimit: 50,
seatsLimit: 10,
name: 'Acme custom plan',
description: 'Description of the deal',
})
await page.goto('/api/stripe/custom-plan-checkout')
await expect(page.getByRole('list').getByText('$239.00')).toBeVisible()
await expect(page.getByText('Subscribe to Acme custom plan')).toBeVisible()
await expect(page.getByText('Description of the deal')).toBeVisible()
})

View File

@@ -0,0 +1,49 @@
import { HStack, Stack, Text } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import React from 'react'
import { CurrentSubscriptionContent } from './CurrentSubscriptionContent'
import { InvoicesList } from './InvoicesList'
import { UsageContent } from './UsageContent/UsageContent'
import { StripeClimateLogo } from '../StripeClimateLogo'
import { TextLink } from '@/components/TextLink'
import { ChangePlanForm } from '../ChangePlanForm'
export const BillingContent = () => {
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
return (
<Stack spacing="10" w="full">
<UsageContent workspace={workspace} />
<Stack spacing="2">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
Typebot is contributing 1% of your subscription to remove CO from
the atmosphere.{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
More info.
</TextLink>
</Text>
</HStack>
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
</Stack>
{workspace.stripeId && <InvoicesList workspace={workspace} />}
</Stack>
)
}

View File

@@ -0,0 +1,91 @@
import {
Text,
HStack,
Link,
Spinner,
Stack,
Button,
Heading,
} from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Plan } from 'db'
import React, { useState } from 'react'
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
import { PlanTag } from '../PlanTag'
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 { showToast } = useToast()
const cancelSubscription = async () => {
if (!stripeId) return
setIsCancelling(true)
const { error } = await cancelSubscriptionQuery(stripeId)
if (error) {
showToast({ description: error.message })
return
}
onCancelSuccess()
setIsCancelling(false)
}
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
return (
<Stack spacing="2">
<Heading fontSize="3xl">Subscription</Heading>
<HStack data-testid="current-subscription">
<Text>Current workspace subscription: </Text>
{isCancelling ? (
<Spinner color="gray.500" size="xs" />
) : (
<>
<PlanTag plan={plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
)}
</>
)}
</HStack>
{isSubscribed && !isCancelling && (
<>
<Stack spacing="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>
</>
)}
</Stack>
)
}

View File

@@ -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 '@/components/icons'
import { Workspace } from 'db'
import Link from 'next/link'
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={Link}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
target="_blank"
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)
}

View File

@@ -0,0 +1,157 @@
import {
Stack,
Flex,
Heading,
Progress,
Text,
Skeleton,
HStack,
Tooltip,
} from '@chakra-ui/react'
import { AlertIcon } from '@/components/icons'
import { Plan, Workspace } from 'db'
import React from 'react'
import { getChatsLimit, getStorageLimit, parseNumberWithCommas } from 'utils'
import { storageToReadable } from './helpers'
import { useUsage } from '../../../hooks/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>
/{' '}
{workspaceChatsLimit === -1
? 'Unlimited'
: 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>
<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>
)
}

View File

@@ -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'
}

View File

@@ -0,0 +1 @@
export { UsageContent } from './UsageContent'

View File

@@ -0,0 +1 @@
export { BillingContent } from './BillingContent'

View File

@@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const cancelSubscriptionQuery = (stripeId: string) =>
sendRequest({
url: `api/stripe/subscription?stripeId=${stripeId}`,
method: 'DELETE',
})

View File

@@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const redirectToBillingPortal = ({
workspaceId,
}: {
workspaceId: string
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)

View File

@@ -0,0 +1,24 @@
import { fetcher } from '@/utils/helpers'
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') === 'true' ? 0 : undefined,
}
)
return {
invoices: data?.invoices ?? [],
isLoading: !error && !data,
}
}

View File

@@ -0,0 +1,98 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { ProPlanContent } from './ProPlanContent'
import { upgradePlanQuery } from '../../queries/upgradePlanQuery'
import { useCurrentSubscriptionInfo } from '../../hooks/useCurrentSubscriptionInfo'
import { StarterPlanContent } from './StarterPlanContent'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
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
const response = await upgradePlanQuery({
stripeId: workspace.stripeId ?? undefined,
user,
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
})
if (typeof response === 'object' && response?.error) {
showToast({ description: response.error.message })
return
}
refreshCurrentSubscriptionInfo({
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace({
plan,
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,
})
}
return (
<Stack spacing={6}>
<HStack alignItems="stretch" spacing="4" w="full">
<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?{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>
Let's chat!
</TextLink>
</Text>
</Stack>
)
}

View File

@@ -0,0 +1,342 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
Tooltip,
Flex,
Tag,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
formatPrice,
computePrice,
} from 'utils'
import { FeaturesList } from './components/FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
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
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}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
selectedChatsLimitIndex
)
: undefined}
</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}
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
selectedStorageLimitIndex
)
: undefined}
</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>
)
}

View File

@@ -0,0 +1,285 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
computePrice,
formatPrice,
} from 'utils'
import { FeaturesList } from './components/FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
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}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
selectedChatsLimitIndex
)
: undefined}
</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}
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
selectedStorageLimitIndex
)
: undefined}
</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>
)
}

View File

@@ -0,0 +1,21 @@
import {
ListProps,
UnorderedList,
Flex,
ListItem,
ListIcon,
} from '@chakra-ui/react'
import { CheckIcon } from '@/components/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>
)

View File

@@ -0,0 +1 @@
export { ChangePlanForm } from './ChangePlanForm'

View File

@@ -0,0 +1,56 @@
import { AlertInfo } from '@/components/AlertInfo'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalOverlay,
Stack,
Button,
HStack,
} from '@chakra-ui/react'
import { ChangePlanForm } from './ChangePlanForm'
export enum LimitReached {
BRAND = 'remove branding',
CUSTOM_DOMAIN = 'add custom domains',
FOLDER = 'create folders',
FILE_INPUT = 'use file input blocks',
ANALYTICS = 'unlock in-depth analytics',
}
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 && (
<AlertInfo>
You need to upgrade your plan in order to {type}
</AlertInfo>
)}
<ChangePlanForm />
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,14 @@
import { Tag, TagProps } from '@chakra-ui/react'
import { LockedIcon } from '@/components/icons'
import { Plan } from 'db'
import { planColorSchemes } from './PlanTag'
export const LockTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => (
<Tag
colorScheme={plan ? planColorSchemes[plan] : 'gray'}
data-testid={`${plan?.toLowerCase()}-lock-tag`}
{...props}
>
<LockedIcon />
</Tag>
)

View File

@@ -0,0 +1,75 @@
import { Tag, TagProps, ThemeTypings } from '@chakra-ui/react'
import { Plan } from 'db'
export const planColorSchemes: Record<Plan, ThemeTypings['colorSchemes']> = {
[Plan.LIFETIME]: 'purple',
[Plan.PRO]: 'blue',
[Plan.OFFERED]: 'orange',
[Plan.STARTER]: 'orange',
[Plan.FREE]: 'gray',
[Plan.CUSTOM]: 'yellow',
}
export const PlanTag = ({
plan,
...props
}: { plan: Plan } & TagProps): JSX.Element => {
switch (plan) {
case Plan.LIFETIME: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="lifetime-plan-tag"
{...props}
>
Lifetime
</Tag>
)
}
case Plan.PRO: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="pro-plan-tag"
{...props}
>
Pro
</Tag>
)
}
case Plan.OFFERED:
case Plan.STARTER: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="starter-plan-tag"
{...props}
>
Starter
</Tag>
)
}
case Plan.FREE: {
return (
<Tag
colorScheme={planColorSchemes[Plan.FREE]}
data-testid="free-plan-tag"
{...props}
>
Free
</Tag>
)
}
case Plan.CUSTOM: {
return (
<Tag
colorScheme={planColorSchemes[Plan.CUSTOM]}
data-testid="free-plan-tag"
{...props}
>
Custom
</Tag>
)
}
}
}

View File

@@ -0,0 +1,61 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const StripeClimateLogo = (props: IconProps) => (
<Icon
width="24px"
height="24px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<linearGradient
id="StripeClimate-gradient-a"
gradientUnits="userSpaceOnUse"
x1="16"
y1="20.6293"
x2="16"
y2="7.8394"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset="0" stopColor="#00d924" />
<stop offset="1" stopColor="#00cb1b" />
</linearGradient>
<path
d="M0 10.82h32c0 8.84-7.16 16-16 16s-16-7.16-16-16z"
fill="url(#StripeClimate-gradient-a)"
/>
<linearGradient
id="StripeClimate-gradient-b"
gradientUnits="userSpaceOnUse"
x1="24"
y1="28.6289"
x2="24"
y2="17.2443"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset=".1562" stopColor="#009c00" />
<stop offset="1" stopColor="#00be20" />
</linearGradient>
<path
d="M32 10.82c0 2.21-1.49 4.65-5.41 4.65-3.42 0-7.27-2.37-10.59-4.65 3.52-2.43 7.39-5.63 10.59-5.63C29.86 5.18 32 8.17 32 10.82z"
fill="url(#StripeClimate-gradient-b)"
/>
<linearGradient
id="StripeClimate-gradient-c"
gradientUnits="userSpaceOnUse"
x1="8"
y1="16.7494"
x2="8"
y2="29.1239"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset="0" stopColor="#ffe37d" />
<stop offset="1" stopColor="#ffc900" />
</linearGradient>
<path
d="M0 10.82c0 2.21 1.49 4.65 5.41 4.65 3.42 0 7.27-2.37 10.59-4.65-3.52-2.43-7.39-5.64-10.59-5.64C2.14 5.18 0 8.17 0 10.82z"
fill="url(#StripeClimate-gradient-c)"
/>
</Icon>
)

View File

@@ -0,0 +1,28 @@
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import React from 'react'
import { isNotDefined } from 'utils'
import { ChangePlanModal } from './ChangePlanModal'
import { LimitReached } from './ChangePlanModal'
type Props = { limitReachedType?: LimitReached } & ButtonProps
export const UpgradeButton = ({ limitReachedType, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
return (
<Button
colorScheme="blue"
{...props}
isLoading={isNotDefined(workspace)}
onClick={onOpen}
>
{props.children ?? 'Upgrade'}
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={limitReachedType}
/>
</Button>
)
}

View File

@@ -0,0 +1,30 @@
import { fetcher } from '@/utils/helpers'
import { Plan } from 'db'
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,
}
}

View File

@@ -0,0 +1,16 @@
import { fetcher } from '@/utils/helpers'
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') === 'true' ? 0 : undefined,
})
return {
data,
isLoading: !error && !data,
}
}

View File

@@ -0,0 +1,8 @@
export { ChangePlanModal, LimitReached } from './components/ChangePlanModal'
export { planToReadable, isFreePlan, isProPlan } from './utils'
export { upgradePlanQuery } from './queries/upgradePlanQuery'
export { BillingContent } from './components/BillingContent'
export { LockTag } from './components/LockTag'
export { useUsage } from './hooks/useUsage'
export { UpgradeButton } from './components/UpgradeButton'
export { PlanTag } from './components/PlanTag'

View File

@@ -0,0 +1,78 @@
import { loadStripe } from '@stripe/stripe-js/pure'
import { Plan, User } from 'db'
import {
env,
guessIfUserIsEuropean,
isDefined,
isEmpty,
sendRequest,
} from 'utils'
type UpgradeProps = {
user: User
stripeId?: string
plan: Plan
workspaceId: string
additionalChats: number
additionalStorage: number
}
export const upgradePlanQuery = async ({
stripeId,
...props
}: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
isDefined(stripeId)
? updatePlan({ ...props, stripeId })
: redirectToCheckout(props)
const updatePlan = async ({
stripeId,
plan,
workspaceId,
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan?: Plan; error?: Error }> => {
const { data, error } = await sendRequest<{ message: string }>({
method: 'PUT',
url: '/api/stripe/subscription',
body: {
workspaceId,
plan,
stripeId,
additionalChats,
additionalStorage,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
},
})
if (error || !data) return { error }
return { newPlan: plan }
}
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,
})
}

View File

@@ -0,0 +1,25 @@
import { Plan, Workspace } from 'db'
import { isDefined, isNotDefined } from 'utils'
export const planToReadable = (plan?: Plan) => {
if (!plan) return
switch (plan) {
case Plan.FREE:
return 'Free'
case Plan.LIFETIME:
return 'Lifetime'
case Plan.OFFERED:
return 'Offered'
case Plan.PRO:
return 'Pro'
}
}
export const isFreePlan = (workspace?: Pick<Workspace, 'plan'>) =>
isNotDefined(workspace) || workspace?.plan === Plan.FREE
export const isProPlan = (workspace?: Pick<Workspace, 'plan'>) =>
isDefined(workspace) &&
(workspace.plan === Plan.PRO ||
workspace.plan === Plan.LIFETIME ||
workspace.plan === Plan.CUSTOM)

View File

@@ -0,0 +1,7 @@
import { Text } from '@chakra-ui/react'
import { EmbedBubbleBlock } from 'models'
export const EmbedBubbleContent = ({ block }: { block: EmbedBubbleBlock }) => {
if (!block.content?.url) return <Text color="gray.500">Click to edit...</Text>
return <Text>Show embed</Text>
}

View File

@@ -0,0 +1,7 @@
import { LayoutIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const EmbedBubbleIcon = (props: IconProps) => (
<LayoutIcon color="blue.500" {...props} />
)

View File

@@ -0,0 +1,47 @@
import { Input, SmartNumberInput } from '@/components/inputs'
import { HStack, Stack, Text } from '@chakra-ui/react'
import { EmbedBubbleContent } from 'models'
import { sanitizeUrl } from 'utils'
type Props = {
content: EmbedBubbleContent
onSubmit: (content: EmbedBubbleContent) => void
}
export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
const handleUrlChange = (url: string) => {
const iframeUrl = sanitizeUrl(
url.trim().startsWith('<iframe') ? extractUrlFromIframe(url) : url
)
onSubmit({ ...content, url: iframeUrl })
}
const handleHeightChange = (height?: number) =>
height && onSubmit({ ...content, height })
return (
<Stack p="2" spacing={6}>
<Stack>
<Input
placeholder="Paste the link or code..."
defaultValue={content?.url ?? ''}
onChange={handleUrlChange}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with PDFs, iframes, websites...
</Text>
</Stack>
<HStack justify="space-between">
<Text>Height: </Text>
<SmartNumberInput
value={content?.height}
onValueChange={handleHeightChange}
/>
</HStack>
</Stack>
)
}
const extractUrlFromIframe = (iframe: string) =>
[...iframe.matchAll(/src="([^"]+)"/g)][0][1]

View File

@@ -0,0 +1,55 @@
import test, { expect } from '@playwright/test'
import { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { typebotViewer } from 'utils/playwright/testHelpers'
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
const siteSrc = 'https://app.cal.com/baptistearno/15min'
test.describe.parallel('Embed bubble block', () => {
test.describe('Content settings', () => {
test('should import and parse embed correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.EMBED,
content: defaultEmbedBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.fill('input[placeholder="Paste the link or code..."]', pdfSrc)
await expect(page.locator('text="Show embed"')).toBeVisible()
})
})
test.describe('Preview', () => {
test('should display embed correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.EMBED,
content: {
url: siteSrc,
height: 700,
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('iframe#embed-bubble-content')
).toHaveAttribute('src', siteSrc)
})
})
})

View File

@@ -0,0 +1,3 @@
export { EmbedBubbleContent } from './components/EmbedBubbleContent'
export { EmbedUploadContent } from './components/EmbedUploadContent'
export { EmbedBubbleIcon } from './components/EmbedBubbleIcon'

View File

@@ -0,0 +1,21 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleBlock } from 'models'
export const ImageBubbleContent = ({ block }: { block: ImageBubbleBlock }) => {
const containsVariables =
block.content?.url?.includes('{{') && block.content.url.includes('}}')
return !block.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={
containsVariables ? '/images/dynamic-image.png' : block.content?.url
}
alt="Group image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}

View File

@@ -0,0 +1,7 @@
import { ImageIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const ImageBubbleIcon = (props: IconProps) => (
<ImageIcon color="blue.500" {...props} />
)

View File

@@ -0,0 +1,130 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { BubbleBlockType, defaultImageBubbleContent } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
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.describe.parallel('Image bubble block', () => {
test.describe('Content settings', () => {
test('should upload image file correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.IMAGE,
content: defaultImageBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img')).toHaveAttribute(
'src',
`${process.env.S3_SSL === 'false' ? 'http://' : 'https://'}${
process.env.S3_ENDPOINT
}${process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''}/${
process.env.S3_BUCKET
}/public/typebots/${typebotId}/blocks/block2`
)
})
test('should import image link correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.IMAGE,
content: defaultImageBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.click('text=Embed link')
await page.fill(
'input[placeholder="Paste the image link..."]',
unsplashImageSrc
)
await expect(page.locator('img')).toHaveAttribute('src', unsplashImageSrc)
})
test('should import gifs correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.IMAGE,
content: defaultImageBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.click('text=Giphy')
const firstGiphyImage = page.locator('.giphy-gif-img >> nth=0')
await expect(firstGiphyImage).toHaveAttribute(
'src',
new RegExp('giphy.com/media', 'gm')
)
const trendingfirstImageSrc = await firstGiphyImage.getAttribute('src')
expect(trendingfirstImageSrc).toMatch(new RegExp('giphy.com/media', 'gm'))
await page.type('[placeholder="Search..."]', 'fun')
await expect(page.locator('[placeholder="Search..."]')).toHaveValue('fun')
await page.waitForTimeout(500)
await expect(firstGiphyImage).toHaveAttribute(
'src',
new RegExp('giphy.com/media', 'gm')
)
const funFirstImageSrc = await firstGiphyImage.getAttribute('src')
expect(funFirstImageSrc).toMatch(new RegExp('giphy.com/media', 'gm'))
expect(trendingfirstImageSrc).not.toBe(funFirstImageSrc)
await firstGiphyImage.click({
force: true,
position: { x: 0, y: 0 },
})
await expect(page.locator('img[alt="Group image"]')).toHaveAttribute(
'src',
new RegExp('giphy.com/media', 'gm')
)
})
})
test.describe('Preview', () => {
test('should display correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.IMAGE,
content: {
url: unsplashImageSrc,
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(typebotViewer(page).locator('img')).toHaveAttribute(
'src',
unsplashImageSrc
)
})
})
})

View File

@@ -0,0 +1,2 @@
export { ImageBubbleContent } from './components/ImageBubbleContent'
export { ImageBubbleIcon } from './components/ImageBubbleIcon'

View File

@@ -0,0 +1,28 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { TextBubbleBlock } from 'models'
import React from 'react'
import { parseVariableHighlight } from '@/utils/helpers'
type Props = {
block: TextBubbleBlock
}
export const TextBubbleContent = ({ block }: Props) => {
const { typebot } = useTypebot()
if (!typebot) return <></>
return (
<Flex
w="90%"
flexDir={'column'}
opacity={block.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
block.content.html === ''
? `<p>Click to edit...</p>`
: parseVariableHighlight(block.content.html, typebot),
}}
/>
)
}

View File

@@ -0,0 +1,179 @@
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import {
Plate,
PlateProvider,
selectEditor,
TElement,
usePlateEditorRef,
} from '@udecode/plate-core'
import { editorStyle, platePlugins } from '@/lib/plate'
import { BaseEditor, BaseSelection, Transforms } from 'slate'
import { ToolBar } from './ToolBar'
import { defaultTextBubbleContent, TextBubbleContent, Variable } from 'models'
import { ReactEditor } from 'slate-react'
import { serializeHtml } from '@udecode/plate-serializer-html'
import { parseHtmlStringToPlainText } from '../../utils'
import { VariableSearchInput } from '@/components/VariableSearchInput'
type TextBubbleEditorContentProps = {
id: string
textEditorValue: TElement[]
onClose: (newContent: TextBubbleContent) => void
}
const TextBubbleEditorContent = ({
id,
textEditorValue,
onClose,
}: TextBubbleEditorContentProps) => {
const editor = usePlateEditorRef()
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<BaseSelection | null>(null)
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const textEditorRef = useRef<HTMLDivElement>(null)
const closeEditor = () => onClose(convertValueToBlockContent(textEditorValue))
useOutsideClick({
ref: textEditorRef,
handler: closeEditor,
})
useEffect(() => {
if (!isVariableDropdownOpen) return
const el = varDropdownRef.current
if (!el) return
const { top, left } = computeTargetCoord()
el.style.top = `${top}px`
el.style.left = `${left}px`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVariableDropdownOpen])
const computeTargetCoord = () => {
const selection = window.getSelection()
const relativeParent = textEditorRef.current
if (!selection || !relativeParent) return { top: 0, left: 0 }
const range = selection.getRangeAt(0)
const selectionBoundingRect = range.getBoundingClientRect()
const relativeRect = relativeParent.getBoundingClientRect()
return {
top: selectionBoundingRect.bottom - relativeRect.top,
left: selectionBoundingRect.left - relativeRect.left,
}
}
const convertValueToBlockContent = (value: TElement[]): TextBubbleContent => {
if (value.length === 0) defaultTextBubbleContent
const html = serializeHtml(editor, {
nodes: value,
})
return {
html,
richText: value,
plainText: parseHtmlStringToPlainText(html),
}
}
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!rememberedSelection.current || !variable) return
Transforms.select(editor as BaseEditor, rememberedSelection.current)
Transforms.insertText(editor as BaseEditor, '{{' + variable.name + '}}')
ReactEditor.focus(editor as unknown as ReactEditor)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.shiftKey) return
if (e.key === 'Enter') closeEditor()
}
return (
<Stack
flex="1"
ref={textEditorRef}
borderWidth="2px"
borderColor="blue.400"
rounded="md"
onMouseDown={handleMouseDown}
pos="relative"
spacing={0}
cursor="text"
>
<ToolBar onVariablesButtonClick={() => setIsVariableDropdownOpen(true)} />
<Plate
id={id}
editableProps={{
style: editorStyle,
autoFocus: true,
onFocus: () => {
if (editor.children.length === 0) return
selectEditor(editor, {
edge: 'end',
})
},
'aria-label': 'Text editor',
onBlur: () => {
rememberedSelection.current = editor.selection
},
onKeyDown: handleKeyDown,
}}
/>
{isVariableDropdownOpen && (
<Flex
pos="absolute"
ref={varDropdownRef}
shadow="lg"
rounded="md"
bgColor="white"
w="250px"
zIndex={10}
>
<VariableSearchInput
onSelectVariable={handleVariableSelected}
placeholder="Search for a variable"
isDefaultOpen
/>
</Flex>
)}
</Stack>
)
}
type TextBubbleEditorProps = {
id: string
initialValue: TElement[]
onClose: (newContent: TextBubbleContent) => void
}
export const TextBubbleEditor = ({
id,
initialValue,
onClose,
}: TextBubbleEditorProps) => {
const [textEditorValue, setTextEditorValue] = useState(initialValue)
return (
<PlateProvider
id={id}
plugins={platePlugins}
initialValue={
initialValue.length === 0
? [{ type: 'p', children: [{ text: '' }] }]
: initialValue
}
onChange={setTextEditorValue}
>
<TextBubbleEditorContent
id={id}
textEditorValue={textEditorValue}
onClose={onClose}
/>
</PlateProvider>
)
}

View File

@@ -0,0 +1,67 @@
import { StackProps, HStack, IconButton } from '@chakra-ui/react'
import {
MARK_BOLD,
MARK_ITALIC,
MARK_UNDERLINE,
} from '@udecode/plate-basic-marks'
import { getPluginType, usePlateEditorRef } from '@udecode/plate-core'
import { LinkToolbarButton } from '@udecode/plate-ui-link'
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
LinkIcon,
UserIcon,
} from '@/components/icons'
type Props = {
onVariablesButtonClick: () => void
} & StackProps
export const ToolBar = ({ onVariablesButtonClick, ...props }: Props) => {
const editor = usePlateEditorRef()
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
onVariablesButtonClick()
}
return (
<HStack
bgColor={'white'}
borderTopRadius="md"
p={2}
w="full"
boxSizing="border-box"
borderBottomWidth={1}
{...props}
>
<IconButton
aria-label="Insert variable"
size="sm"
onMouseDown={handleVariablesButtonMouseDown}
icon={<UserIcon />}
/>
<span data-testid="bold-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_BOLD)}
icon={<BoldIcon />}
/>
</span>
<span data-testid="italic-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_ITALIC)}
icon={<ItalicIcon />}
/>
</span>
<span data-testid="underline-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_UNDERLINE)}
icon={<UnderlineIcon />}
/>
</span>
<span data-testid="link-button">
<LinkToolbarButton icon={<LinkIcon />} />
</span>
</HStack>
)
}

View File

@@ -0,0 +1 @@
export { TextBubbleEditor } from './TextBubbleEditor'

View File

@@ -0,0 +1,7 @@
import { ChatIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const TextBubbleIcon = (props: IconProps) => (
<ChatIcon color="blue.500" {...props} />
)

View File

@@ -0,0 +1,3 @@
export { TextBubbleEditor } from './components/TextBubbleEditor'
export { TextBubbleContent } from './components/TextBubbleContent'
export { TextBubbleIcon } from './components/TextBubbleIcon'

View File

@@ -0,0 +1,67 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
test.describe('Text bubble block', () => {
test('rich text features should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.TEXT,
content: defaultTextBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('[data-testid="bold-button"]')
await page.type('div[role="textbox"]', 'Bold text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="bold-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Italic text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="underline-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Underlined text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="bold-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Everything text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.type('div[role="textbox"]', 'My super link')
await page.waitForTimeout(300)
await page.press('div[role="textbox"]', 'Shift+Meta+ArrowLeft')
await page.click('[data-testid="link-button"]')
await page.fill('input[placeholder="Paste link"]', 'https://github.com')
await page.press('input[placeholder="Paste link"]', 'Enter')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('button[aria-label="Insert variable"]')
await page.fill('[data-testid="variables-input"]', 'test')
await page.click('text=Create "test"')
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('span.slate-bold >> nth=0')
).toHaveText('Bold text')
await expect(
typebotViewer(page).locator('span.slate-italic >> nth=0')
).toHaveText('Italic text')
await expect(
typebotViewer(page).locator('span.slate-underline >> nth=0')
).toHaveText('Underlined text')
await expect(
typebotViewer(page).locator('a[href="https://github.com"]')
).toHaveText('My super link')
})
})

View File

@@ -0,0 +1,13 @@
import { Parser } from 'htmlparser2'
export const parseHtmlStringToPlainText = (html: string): string => {
let label = ''
const parser = new Parser({
ontext(text) {
label += `${text}`
},
})
parser.write(html)
parser.end()
return label
}

View File

@@ -0,0 +1,52 @@
import { Box, Text } from '@chakra-ui/react'
import { VideoBubbleBlock, VideoBubbleContentType } from 'models'
export const VideoBubbleContent = ({ block }: { block: VideoBubbleBlock }) => {
if (!block.content?.url || !block.content.type)
return <Text color="gray.500">Click to edit...</Text>
switch (block.content.type) {
case VideoBubbleContentType.URL:
return (
<Box w="full" h="120px" pos="relative">
<video
key={block.content.url}
controls
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
>
<source src={block.content.url} />
</video>
</Box>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
block.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<Box w="full" h="120px" pos="relative">
<iframe
src={`${baseUrl}/${block.content.id}`}
allowFullScreen
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
pointerEvents: 'none',
}}
/>
</Box>
)
}
}
}

View File

@@ -0,0 +1,7 @@
import { FilmIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const VideoBubbleIcon = (props: IconProps) => (
<FilmIcon color="blue.500" {...props} />
)

View File

@@ -0,0 +1,37 @@
import { Stack, Text } from '@chakra-ui/react'
import { VideoBubbleContent, VideoBubbleContentType } from 'models'
import urlParser from 'js-video-url-parser/lib/base'
import 'js-video-url-parser/lib/provider/vimeo'
import 'js-video-url-parser/lib/provider/youtube'
import { isDefined } from 'utils'
import { Input } from '@/components/inputs'
type Props = {
content?: VideoBubbleContent
onSubmit: (content: VideoBubbleContent) => void
}
export const VideoUploadContent = ({ content, onSubmit }: Props) => {
const handleUrlChange = (url: string) => {
const info = urlParser.parse(url)
return isDefined(info) && info.provider && info.id
? onSubmit({
type: info.provider as VideoBubbleContentType,
url,
id: info.id,
})
: onSubmit({ type: VideoBubbleContentType.URL, url })
}
return (
<Stack p="2">
<Input
placeholder="Paste the video link..."
defaultValue={content?.url ?? ''}
onChange={handleUrlChange}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with Youtube, Vimeo and others
</Text>
</Stack>
)
}

View File

@@ -0,0 +1,3 @@
export { VideoUploadContent } from './components/VideoUploadContent'
export { VideoBubbleContent } from './components/VideoBubbleContent'
export { VideoBubbleIcon } from './components/VideoBubbleIcon'

View File

@@ -0,0 +1,113 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import {
BubbleBlockType,
defaultVideoBubbleContent,
VideoBubbleContentType,
} from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
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.describe.parallel('Video bubble block', () => {
test.describe('Content settings', () => {
test('should import video url correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.VIDEO,
content: defaultVideoBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.fill('input[placeholder="Paste the video link..."]', videoSrc)
await expect(page.locator('video > source')).toHaveAttribute(
'src',
videoSrc
)
})
})
test.describe('Preview', () => {
test('should display video correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.VIDEO,
content: {
type: VideoBubbleContentType.URL,
url: videoSrc,
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('video > source')
).toHaveAttribute('src', videoSrc)
})
test('should display youtube video correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.VIDEO,
content: {
type: VideoBubbleContentType.YOUTUBE,
url: youtubeVideoSrc,
id: 'dQw4w9WgXcQ',
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(typebotViewer(page).locator('iframe')).toHaveAttribute(
'src',
'https://www.youtube.com/embed/dQw4w9WgXcQ'
)
})
test('should display vimeo video correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.VIDEO,
content: {
type: VideoBubbleContentType.VIMEO,
url: vimeoVideoSrc,
id: '649301125',
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(typebotViewer(page).locator('iframe')).toHaveAttribute(
'src',
'https://player.vimeo.com/video/649301125'
)
})
})
})

View File

@@ -0,0 +1,99 @@
import test, { expect } from '@playwright/test'
import {
createTypebots,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultChoiceInputOptions, InputBlockType, ItemType } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.parallel('Buttons input block', () => {
test('can edit button items', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.CHOICE,
items: [
{
id: 'choice1',
blockId: 'block1',
type: ItemType.BUTTON,
},
],
options: { ...defaultChoiceInputOptions },
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.fill('input[value="Click to edit"]', 'Item 1')
await page.press('input[value="Item 1"]', 'Enter')
await page.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter')
await page.fill('input[value="Click to edit"]', 'Item 3')
await page.press('input[value="Item 3"]', 'Enter')
await page.press('input[value="Click to edit"]', 'Escape')
await page.click('text=Item 2', { button: 'right' })
await page.click('text=Delete')
await expect(page.locator('text=Item 2')).toBeHidden()
await page.click('text=Preview')
const item3Button = typebotViewer(page).locator('button >> text=Item 3')
await item3Button.click()
await expect(item3Button).toBeHidden()
await expect(typebotViewer(page).locator('text=Item 3')).toBeVisible()
await page.click('button[aria-label="Close"]')
await page.click('[data-testid="block2-icon"]')
await page.click('text=Multiple choice?')
await page.fill('#button', 'Go')
await page.click('[data-testid="block2-icon"]')
await page.locator('text=Item 1').hover()
await page.waitForTimeout(1000)
await page.click('[aria-label="Add item"]')
await page.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter')
await page.click('text=Preview')
await typebotViewer(page).locator('button >> text="Item 3"').click()
await typebotViewer(page).locator('button >> text="Item 1"').click()
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text="Item 3, Item 1"')
).toBeVisible()
})
})
test('Variable buttons should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/inputs/variableButton.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await typebotViewer(page).locator('text=Variable item').click()
await expect(typebotViewer(page).locator('text=Variable item')).toBeVisible()
await expect(typebotViewer(page).locator('text=Ok great!')).toBeVisible()
await page.click('text="Item 1"')
await page.fill('input[value="Item 1"]', '{{Item 2}}')
await page.click('[data-testid="block1-icon"]')
await page.click('text=Multiple choice?')
await page.click('text="Restart"')
await typebotViewer(page).locator('text="Variable item" >> nth=0').click()
await typebotViewer(page).locator('text="Variable item" >> nth=1').click()
await typebotViewer(page).locator('text="Send"').click()
await expect(
typebotViewer(page).locator('text="Variable item, Variable item"')
).toBeVisible()
})

View File

@@ -0,0 +1,93 @@
import {
EditablePreview,
EditableInput,
Editable,
Fade,
IconButton,
Flex,
} from '@chakra-ui/react'
import { PlusIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { ButtonItem, ItemIndices, ItemType } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { isNotDefined } from 'utils'
type Props = {
item: ButtonItem
indices: ItemIndices
isMouseOver: boolean
}
export const ButtonNodeContent = ({ item, indices, isMouseOver }: Props) => {
const { deleteItem, updateItem, createItem } = useTypebot()
const [initialContent] = useState(item.content ?? '')
const [itemValue, setItemValue] = useState(item.content ?? 'Click to edit')
const editableRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (itemValue !== item.content)
setItemValue(item.content ?? 'Click to edit')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item])
const handleInputSubmit = () => {
if (itemValue === '') deleteItem(indices)
else
updateItem(indices, { content: itemValue === '' ? undefined : itemValue })
}
const handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape' && itemValue === 'Click to edit') deleteItem(indices)
if (e.key === 'Enter' && itemValue !== '' && initialContent === '')
handlePlusClick()
}
const handlePlusClick = () => {
const itemIndex = indices.itemIndex + 1
createItem(
{ blockId: item.blockId, type: ItemType.BUTTON },
{ ...indices, itemIndex }
)
}
return (
<Flex px={4} py={2} justify="center" w="90%" pos="relative">
<Editable
ref={editableRef}
flex="1"
startWithEditView={isNotDefined(item.content)}
value={itemValue}
onChange={setItemValue}
onSubmit={handleInputSubmit}
onKeyDownCapture={handleKeyPress}
maxW="180px"
>
<EditablePreview
w="full"
color={item.content !== 'Click to edit' ? 'inherit' : 'gray.500'}
cursor="pointer"
/>
<EditableInput />
</Editable>
<Fade
in={isMouseOver}
style={{
position: 'absolute',
bottom: '-15px',
zIndex: 3,
left: '90px',
}}
unmountOnExit
>
<IconButton
aria-label="Add item"
icon={<PlusIcon />}
size="xs"
shadow="md"
colorScheme="gray"
onClick={handlePlusClick}
/>
</Fade>
</Flex>
)
}

View File

@@ -0,0 +1,7 @@
import { CheckSquareIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const ButtonsInputIcon = (props: IconProps) => (
<CheckSquareIcon color="orange.500" {...props} />
)

View File

@@ -0,0 +1,54 @@
import { Input } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { ChoiceInputOptions, Variable } from 'models'
import React from 'react'
type ButtonsOptionsFormProps = {
options?: ChoiceInputOptions
onOptionsChange: (options: ChoiceInputOptions) => void
}
export const ButtonsOptionsForm = ({
options,
onOptionsChange,
}: ButtonsOptionsFormProps) => {
const handleIsMultipleChange = (isMultipleChoice: boolean) =>
options && onOptionsChange({ ...options, isMultipleChoice })
const handleButtonLabelChange = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const handleVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Multiple choice?"
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={handleIsMultipleChange}
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options?.buttonLabel ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,3 @@
export { ButtonsOptionsForm } from './components/ButtonsOptionsForm'
export { ButtonNodeContent } from './components/ButtonNodeContent'
export { ButtonsInputIcon } from './components/ButtonsInputIcon'

View File

@@ -0,0 +1,7 @@
import { CalendarIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const DateInputIcon = (props: IconProps) => (
<CalendarIcon color="orange.500" {...props} />
)

View File

@@ -0,0 +1,87 @@
import { Input } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { DateInputOptions, Variable } from 'models'
import React from 'react'
type DateInputSettingsBodyProps = {
options: DateInputOptions
onOptionsChange: (options: DateInputOptions) => void
}
export const DateInputSettingsBody = ({
options,
onOptionsChange,
}: DateInputSettingsBodyProps) => {
const handleFromChange = (from: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, from } })
const handleToChange = (to: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, to } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleIsRangeChange = (isRange: boolean) =>
onOptionsChange({ ...options, isRange })
const handleHasTimeChange = (hasTime: boolean) =>
onOptionsChange({ ...options, hasTime })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Is range?"
initialValue={options.isRange}
onCheckChange={handleIsRangeChange}
/>
<SwitchWithLabel
label="With time?"
initialValue={options.isRange}
onCheckChange={handleHasTimeChange}
/>
{options.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="from">
From label:
</FormLabel>
<Input
id="from"
defaultValue={options.labels.from}
onChange={handleFromChange}
/>
</Stack>
)}
{options?.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="to">
To label:
</FormLabel>
<Input
id="to"
defaultValue={options.labels.to}
onChange={handleToChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
export const DateNodeContent = () => (
<Text color={'gray.500'}>Pick a date...</Text>
)

View File

@@ -0,0 +1,61 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultDateInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Date input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.DATE,
options: defaultDateInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('[data-testid="from-date"]')
).toHaveAttribute('type', 'date')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await typebotViewer(page)
.locator('[data-testid="from-date"]')
.fill('2021-01-01')
await typebotViewer(page).locator(`button`).click()
await expect(typebotViewer(page).locator('text="01/01/2021"')).toBeVisible()
await page.click(`text=Pick a date...`)
await page.click('text=Is range?')
await page.click('text=With time?')
await page.fill('#from', 'Previous:')
await page.fill('#to', 'After:')
await page.fill('#button', 'Go')
await page.click('text=Restart')
await expect(
typebotViewer(page).locator(`[data-testid="from-date"]`)
).toHaveAttribute('type', 'datetime-local')
await expect(
typebotViewer(page).locator(`[data-testid="to-date"]`)
).toHaveAttribute('type', 'datetime-local')
await typebotViewer(page)
.locator('[data-testid="from-date"]')
.fill('2021-01-01T11:00')
await typebotViewer(page)
.locator('[data-testid="to-date"]')
.fill('2022-01-01T09:00')
await typebotViewer(page).locator(`button`).click()
await expect(
typebotViewer(page).locator(
'text="01/01/2021, 11:00 AM to 01/01/2022, 09:00 AM"'
)
).toBeVisible()
})
})

View File

@@ -0,0 +1,3 @@
export { DateInputSettingsBody } from './components/DateInputSettingsBody'
export { DateNodeContent } from './components/DateNodeContent'
export { DateInputIcon } from './components/DateInputIcon'

View File

@@ -0,0 +1,7 @@
import { EmailIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const EmailInputIcon = (props: IconProps) => (
<EmailIcon color="orange.500" {...props} />
)

View File

@@ -0,0 +1,11 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { EmailInputBlock } from 'models'
type Props = {
placeholder: EmailInputBlock['options']['labels']['placeholder']
}
export const EmailInputNodeContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@@ -0,0 +1,68 @@
import { Input } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { EmailInputOptions, Variable } from 'models'
import React from 'react'
type EmailInputSettingsBodyProps = {
options: EmailInputOptions
onOptionsChange: (options: EmailInputOptions) => void
}
export const EmailInputSettingsBody = ({
options,
onOptionsChange,
}: EmailInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleRetryMessageChange = (retryMessageContent: string) =>
onOptionsChange({ ...options, retryMessageContent })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="retry">
Retry message:
</FormLabel>
<Input
id="retry"
defaultValue={options.retryMessageContent}
onChange={handleRetryMessageChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,59 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultEmailInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Email input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.EMAIL,
options: defaultEmailInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultEmailInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'email')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultEmailInputOptions.labels.placeholder}`)
await page.fill(
`input[value="${defaultEmailInputOptions.labels.placeholder}"]`,
'Your email...'
)
await expect(page.locator('text=Your email...')).toBeVisible()
await page.fill('#button', 'Go')
await page.fill(
`input[value="${defaultEmailInputOptions.retryMessageContent}"]`,
'Try again bro'
)
await page.click('text=Restart')
await typebotViewer(page)
.locator(`input[placeholder="Your email..."]`)
.fill('test@test')
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text=Try again bro')
).toBeVisible()
await typebotViewer(page)
.locator(`input[placeholder="Your email..."]`)
.fill('test@test.com')
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text=test@test.com')
).toBeVisible()
})
})

View File

@@ -0,0 +1,3 @@
export { EmailInputSettingsBody } from './components/EmailInputSettingsBody'
export { EmailInputNodeContent } from './components/EmailInputNodeContent'
export { EmailInputIcon } from './components/EmailInputIcon'

View File

@@ -0,0 +1,12 @@
import { Text } from '@chakra-ui/react'
import { FileInputOptions } from 'models'
type Props = {
options: FileInputOptions
}
export const FileInputContent = ({ options: { isMultipleAllowed } }: Props) => (
<Text noOfLines={1} pr="6">
Collect {isMultipleAllowed ? 'files' : 'file'}
</Text>
)

View File

@@ -0,0 +1,7 @@
import { UploadIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const FileInputIcon = (props: IconProps) => (
<UploadIcon color="orange.500" {...props} />
)

View File

@@ -0,0 +1,81 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { CodeEditor } from '@/components/CodeEditor'
import { FileInputOptions, Variable } from 'models'
import React from 'react'
import { Input, SmartNumberInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
type Props = {
options: FileInputOptions
onOptionsChange: (options: FileInputOptions) => void
}
export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handlePlaceholderLabelChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleMultipleFilesChange = (isMultipleAllowed: boolean) =>
onOptionsChange({ ...options, isMultipleAllowed })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleSizeLimitChange = (sizeLimit?: number) =>
onOptionsChange({ ...options, sizeLimit })
const handleRequiredChange = (isRequired: boolean) =>
onOptionsChange({ ...options, isRequired })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Required?"
initialValue={options.isRequired ?? true}
onCheckChange={handleRequiredChange}
/>
<SwitchWithLabel
label="Allow multiple files?"
initialValue={options.isMultipleAllowed}
onCheckChange={handleMultipleFilesChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="limit">
Size limit (MB):
</FormLabel>
<SmartNumberInput
id="limit"
value={options.sizeLimit ?? 10}
onValueChange={handleSizeLimitChange}
/>
</Stack>
<Stack>
<FormLabel mb="0">Placeholder:</FormLabel>
<CodeEditor
lang="html"
onChange={handlePlaceholderLabelChange}
value={options.labels.placeholder}
height={'100px'}
withVariableButton={false}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
withVariableButton={false}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save upload URL{options.isMultipleAllowed ? 's' : ''} in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,81 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultFileInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
import { freeWorkspaceId } from 'utils/playwright/databaseSetup'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.configure({ mode: 'parallel' })
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.FILE,
options: defaultFileInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(`text=Click to upload`)
).toBeVisible()
await expect(typebotViewer(page).locator(`text="Skip"`)).toBeHidden()
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([getTestAsset('avatar.jpg')])
await expect(typebotViewer(page).locator(`text=File uploaded`)).toBeVisible()
await page.click('text="Collect file"')
await page.click('text="Required?"')
await page.click('text="Allow multiple files?"')
await page.fill('div[contenteditable=true]', '<strong>Upload now!!</strong>')
await page.fill('[value="Upload"]', 'Go')
await page.fill('input[value="10"]', '20')
await page.click('text="Restart"')
await expect(typebotViewer(page).locator(`text="Skip"`)).toBeVisible()
await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible()
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('avatar.jpg'),
getTestAsset('avatar.jpg'),
getTestAsset('avatar.jpg'),
])
await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
await typebotViewer(page).locator('text="Go 3 files"').click()
await expect(
typebotViewer(page).locator(`text="3 files uploaded"`)
).toBeVisible()
})
test.describe('Free workspace', () => {
test("shouldn't be able to publish typebot", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.FILE,
options: defaultFileInputOptions,
}),
workspaceId: freeWorkspaceId,
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text="Collect file"')
await page.click('text="Allow multiple files?"')
await page.click('text="Publish"')
await expect(
page.locator(
'text="You need to upgrade your plan in order to use file input blocks"'
)
).toBeVisible()
})
})

View File

@@ -0,0 +1,3 @@
export { FileInputSettings } from './components/FileInputSettings'
export { FileInputContent } from './components/FileInputContent'
export { FileInputIcon } from './components/FileInputIcon'

View File

@@ -0,0 +1,7 @@
import { NumberIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const NumberInputIcon = (props: IconProps) => (
<NumberIcon color="orange.500" {...props} />
)

View File

@@ -0,0 +1,94 @@
import { Input, SmartNumberInput } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { removeUndefinedFields } from '@/utils/helpers'
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
import { NumberInputOptions, Variable } from 'models'
import React from 'react'
type NumberInputSettingsBodyProps = {
options: NumberInputOptions
onOptionsChange: (options: NumberInputOptions) => void
}
export const NumberInputSettingsBody = ({
options,
onOptionsChange,
}: NumberInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleMinChange = (min?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, min }))
const handleMaxChange = (max?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleBlockChange = (block?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, block }))
const handleVariableChange = (variable?: Variable) => {
onOptionsChange({ ...options, variableId: variable?.id })
}
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options?.labels?.button ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="min">
Min:
</FormLabel>
<SmartNumberInput
id="min"
value={options.min}
onValueChange={handleMinChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="max">
Max:
</FormLabel>
<SmartNumberInput
id="max"
value={options.max}
onValueChange={handleMaxChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="step">
Step:
</FormLabel>
<SmartNumberInput
id="step"
value={options.step}
onValueChange={handleBlockChange}
/>
</HStack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

Some files were not shown because too many files have changed in this diff Show More