♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
121
apps/builder/src/features/account/UserProvider.tsx
Normal file
121
apps/builder/src/features/account/UserProvider.tsx
Normal 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)
|
||||
49
apps/builder/src/features/account/account.spec.ts
Normal file
49
apps/builder/src/features/account/account.spec.ts
Normal 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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ApiTokensList } from './ApiTokensList'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MyAccountForm } from './MyAccountForm'
|
||||
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal file
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
3
apps/builder/src/features/account/index.ts
Normal file
3
apps/builder/src/features/account/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { UserProvider, useUser } from './UserProvider'
|
||||
export type { ApiTokenFromServer } from './types'
|
||||
export { MyAccountForm } from './components/MyAccountForm'
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
1
apps/builder/src/features/account/types.ts
Normal file
1
apps/builder/src/features/account/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }
|
||||
32
apps/builder/src/features/analytics/analytics.spec.ts
Normal file
32
apps/builder/src/features/analytics/analytics.spec.ts
Normal 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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
25
apps/builder/src/features/analytics/hooks/useAnswersCount.ts
Normal file
25
apps/builder/src/features/analytics/hooks/useAnswersCount.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
2
apps/builder/src/features/analytics/index.ts
Normal file
2
apps/builder/src/features/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AnalyticsGraphContainer } from './components/AnalyticsGraphContainer'
|
||||
export type { AnswersCount } from './types'
|
||||
1
apps/builder/src/features/analytics/types.ts
Normal file
1
apps/builder/src/features/analytics/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type AnswersCount = { groupId: string; totalAnswers: number }
|
||||
14
apps/builder/src/features/auth/api/getAuthenticatedUser.ts
Normal file
14
apps/builder/src/features/auth/api/getAuthenticatedUser.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
122
apps/builder/src/features/auth/components/SignInForm.tsx
Normal file
122
apps/builder/src/features/auth/components/SignInForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
apps/builder/src/features/auth/components/SignInPage.tsx
Normal file
38
apps/builder/src/features/auth/components/SignInPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
apps/builder/src/features/auth/components/SocialLoginButtons.tsx
Normal file
110
apps/builder/src/features/auth/components/SocialLoginButtons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
3
apps/builder/src/features/auth/components/logos/index.ts
Normal file
3
apps/builder/src/features/auth/components/logos/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AzureAdLogo } from './AzureAdLogo'
|
||||
export { GitlabLogo } from './GitlabLogo'
|
||||
export { FacebookLogo } from './FacebookLogo'
|
||||
15
apps/builder/src/features/auth/constants.ts
Normal file
15
apps/builder/src/features/auth/constants.ts
Normal 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(),
|
||||
}
|
||||
3
apps/builder/src/features/auth/index.ts
Normal file
3
apps/builder/src/features/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SignInPage } from './components/SignInPage'
|
||||
export { getAuthenticatedUser } from './api/getAuthenticatedUser'
|
||||
export { mockedUser } from './constants'
|
||||
259
apps/builder/src/features/billing/billing.spec.ts
Normal file
259
apps/builder/src/features/billing/billing.spec.ts
Normal 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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const storageToReadable = (bytes: number) => {
|
||||
if (bytes == 0) {
|
||||
return '0'
|
||||
}
|
||||
const e = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UsageContent } from './UsageContent'
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingContent } from './BillingContent'
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const cancelSubscriptionQuery = (stripeId: string) =>
|
||||
sendRequest({
|
||||
url: `api/stripe/subscription?stripeId=${stripeId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const redirectToBillingPortal = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fetcher } from '@/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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangePlanForm } from './ChangePlanForm'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
14
apps/builder/src/features/billing/components/LockTag.tsx
Normal file
14
apps/builder/src/features/billing/components/LockTag.tsx
Normal 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>
|
||||
)
|
||||
75
apps/builder/src/features/billing/components/PlanTag.tsx
Normal file
75
apps/builder/src/features/billing/components/PlanTag.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
16
apps/builder/src/features/billing/hooks/useUsage.ts
Normal file
16
apps/builder/src/features/billing/hooks/useUsage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
apps/builder/src/features/billing/index.ts
Normal file
8
apps/builder/src/features/billing/index.ts
Normal 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'
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
25
apps/builder/src/features/billing/utils.ts
Normal file
25
apps/builder/src/features/billing/utils.ts
Normal 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)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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]
|
||||
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal file
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { EmbedBubbleContent } from './components/EmbedBubbleContent'
|
||||
export { EmbedUploadContent } from './components/EmbedUploadContent'
|
||||
export { EmbedBubbleIcon } from './components/EmbedBubbleIcon'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
130
apps/builder/src/features/blocks/bubbles/image/image.spec.ts
Normal file
130
apps/builder/src/features/blocks/bubbles/image/image.spec.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
apps/builder/src/features/blocks/bubbles/image/index.ts
Normal file
2
apps/builder/src/features/blocks/bubbles/image/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ImageBubbleContent } from './components/ImageBubbleContent'
|
||||
export { ImageBubbleIcon } from './components/ImageBubbleIcon'
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TextBubbleEditor } from './TextBubbleEditor'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TextBubbleEditor } from './components/TextBubbleEditor'
|
||||
export { TextBubbleContent } from './components/TextBubbleContent'
|
||||
export { TextBubbleIcon } from './components/TextBubbleIcon'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
13
apps/builder/src/features/blocks/bubbles/textBubble/utils.ts
Normal file
13
apps/builder/src/features/blocks/bubbles/textBubble/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
apps/builder/src/features/blocks/bubbles/video/index.ts
Normal file
3
apps/builder/src/features/blocks/bubbles/video/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { VideoUploadContent } from './components/VideoUploadContent'
|
||||
export { VideoBubbleContent } from './components/VideoBubbleContent'
|
||||
export { VideoBubbleIcon } from './components/VideoBubbleIcon'
|
||||
113
apps/builder/src/features/blocks/bubbles/video/video.spec.ts
Normal file
113
apps/builder/src/features/blocks/bubbles/video/video.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
apps/builder/src/features/blocks/inputs/buttons/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/buttons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ButtonsOptionsForm } from './components/ButtonsOptionsForm'
|
||||
export { ButtonNodeContent } from './components/ButtonNodeContent'
|
||||
export { ButtonsInputIcon } from './components/ButtonsInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
61
apps/builder/src/features/blocks/inputs/date/date.spec.ts
Normal file
61
apps/builder/src/features/blocks/inputs/date/date.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
3
apps/builder/src/features/blocks/inputs/date/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/date/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DateInputSettingsBody } from './components/DateInputSettingsBody'
|
||||
export { DateNodeContent } from './components/DateNodeContent'
|
||||
export { DateInputIcon } from './components/DateInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { EmailInputSettingsBody } from './components/EmailInputSettingsBody'
|
||||
export { EmailInputNodeContent } from './components/EmailInputNodeContent'
|
||||
export { EmailInputIcon } from './components/EmailInputIcon'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FileInputSettings } from './components/FileInputSettings'
|
||||
export { FileInputContent } from './components/FileInputContent'
|
||||
export { FileInputIcon } from './components/FileInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user