2
0

Add WhatsApp integration beta test (#722)

Related to #401
This commit is contained in:
Baptiste Arnaud
2023-08-29 10:01:28 +02:00
parent 036b407a11
commit b852b4af0b
136 changed files with 6694 additions and 5383 deletions

View File

@ -40,6 +40,18 @@ const nextConfig = {
},
]
},
async rewrites() {
return process.env.NEXT_PUBLIC_POSTHOG_KEY
? [
{
source: '/ingest/:path*',
destination:
(process.env.NEXT_PUBLIC_POSTHOG_HOST ??
'https://app.posthog.com') + '/:path*',
},
]
: []
},
}
const sentryWebpackPluginOptions = {

View File

@ -66,6 +66,7 @@
"got": "12.6.0",
"immer": "10.0.2",
"jsonwebtoken": "9.0.1",
"libphonenumber-js": "1.10.37",
"micro": "10.0.1",
"micro-cors": "0.1.1",
"minio": "7.1.1",
@ -76,6 +77,7 @@
"nodemailer": "6.9.3",
"nprogress": "0.2.0",
"papaparse": "5.4.1",
"posthog-js": "^1.77.1",
"posthog-node": "3.1.1",
"prettier": "2.8.8",
"qs": "6.11.2",
@ -114,9 +116,9 @@
"@types/react": "18.2.15",
"@types/tinycolor2": "1.4.3",
"dotenv-cli": "^7.2.1",
"next-runtime-env": "^1.6.2",
"eslint": "8.44.0",
"eslint-config-custom": "workspace:*",
"next-runtime-env": "^1.6.2",
"superjson": "^1.12.4",
"typescript": "5.1.6",
"zod": "3.21.4"

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

View File

@ -1,9 +1,9 @@
import {
Button,
ButtonProps,
chakra,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
Portal,
@ -27,7 +27,7 @@ export const DropdownList = <T extends readonly any[]>({
items,
placeholder = '',
...props
}: Props<T> & MenuButtonProps) => {
}: Props<T> & ButtonProps) => {
const handleMenuItemClick = (operator: T[number]) => () => {
onItemSelect(operator)
}

View File

@ -26,7 +26,7 @@ export const TextLink = ({
>
<chakra.span textDecor="underline" display="inline-block" {...textProps}>
{isExternal ? (
<HStack spacing={1}>
<HStack as="span" spacing={1}>
<chakra.span noOfLines={noOfLines} maxW="100%">
{children}
</chakra.span>

View File

@ -1,4 +1,9 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Flex,
HStack,
IconButton,
@ -35,6 +40,7 @@ export const Toast = ({
onClose,
}: ToastProps) => {
const bgColor = useColorModeValue('white', 'gray.800')
const detailsLabelColor = useColorModeValue('gray.600', 'gray.400')
return (
<Flex
@ -56,14 +62,29 @@ export const Toast = ({
</Stack>
{details && (
<CodeEditor
isReadOnly
value={details.content}
lang={details.lang}
minWidth="300px"
maxHeight="200px"
maxWidth="calc(450px - 100px)"
/>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton
justifyContent="space-between"
fontSize="sm"
py="1"
color={detailsLabelColor}
>
Details
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<CodeEditor
isReadOnly
value={details.content}
lang={details.lang}
minWidth="300px"
maxHeight="200px"
maxWidth="calc(450px - 100px)"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
{(secondaryButton || primaryButton) && (
<HStack>

View File

@ -1,5 +1,6 @@
import {
FormControl,
FormControlProps,
FormLabel,
HStack,
Switch,
@ -13,13 +14,15 @@ export type SwitchWithLabelProps = {
initialValue: boolean
moreInfoContent?: string
onCheckChange: (isChecked: boolean) => void
} & SwitchProps
justifyContent?: FormControlProps['justifyContent']
} & Omit<SwitchProps, 'value' | 'justifyContent'>
export const SwitchWithLabel = ({
label,
initialValue,
moreInfoContent,
onCheckChange,
justifyContent = 'space-between',
...switchProps
}: SwitchWithLabelProps) => {
const [isChecked, setIsChecked] = useState(initialValue)
@ -28,8 +31,9 @@ export const SwitchWithLabel = ({
setIsChecked(!isChecked)
onCheckChange(!isChecked)
}
return (
<FormControl as={HStack} justifyContent="space-between">
<FormControl as={HStack} justifyContent={justifyContent}>
<FormLabel mb="0">
{label}
{moreInfoContent && (

View File

@ -35,7 +35,13 @@ export type TextInputProps = {
isDisabled?: boolean
} & Pick<
InputProps,
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus' | 'size'
| 'autoComplete'
| 'onFocus'
| 'onKeyUp'
| 'type'
| 'autoFocus'
| 'size'
| 'maxWidth'
>
export const TextInput = forwardRef(function TextInput(
@ -56,6 +62,7 @@ export const TextInput = forwardRef(function TextInput(
onFocus,
onKeyUp,
size,
maxWidth,
}: TextInputProps,
ref
) {
@ -122,6 +129,7 @@ export const TextInput = forwardRef(function TextInput(
onBlur={updateCarretPosition}
onChange={(e) => changeValue(e.target.value)}
size={size}
maxWidth={maxWidth}
/>
)

View File

@ -0,0 +1,60 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const WhatsAppLogo = (props: IconProps) => (
<Icon viewBox="0 0 163 164" {...props}>
<g filter="url(#filter0_f_1006_58)">
<path
d="M48.5649 132.648L50.7999 133.972C60.1869 139.543 70.9499 142.49 81.9259 142.495H81.9489C115.656 142.495 143.088 115.069 143.102 81.3599C143.108 65.0249 136.753 49.6639 125.207 38.1089C119.544 32.4103 112.807 27.8915 105.386 24.8141C97.9646 21.7368 90.0068 20.162 81.9729 20.1809C48.2399 20.1809 20.8069 47.6039 20.7949 81.3109C20.7783 92.8208 24.0195 104.101 30.1439 113.846L31.5989 116.158L25.4199 138.716L48.5649 132.648ZM7.75391 156.192L18.1929 118.078C11.7549 106.924 8.36791 94.2699 8.37191 81.3059C8.38891 40.7499 41.3929 7.75586 81.9499 7.75586C101.631 7.76586 120.104 15.4249 133.997 29.3279C147.89 43.2309 155.534 61.7109 155.527 81.3649C155.509 121.918 122.5 154.918 81.9489 154.918H81.9169C69.6039 154.913 57.5049 151.824 46.7579 145.964L7.75391 156.192Z"
fill="#B3B3B3"
/>
</g>
<path
d="M7 155.436L17.439 117.322C10.9899 106.141 7.60242 93.4575 7.618 80.55C7.635 39.994 40.639 7 81.196 7C100.877 7.01 119.35 14.669 133.243 28.572C147.136 42.475 154.78 60.955 154.773 80.609C154.755 121.162 121.746 154.162 81.195 154.162H81.163C68.85 154.157 56.751 151.068 46.004 145.208L7 155.436Z"
fill="white"
/>
<path
d="M81.2171 19.425C47.4841 19.425 20.0511 46.848 20.0391 80.555C20.0225 92.065 23.2637 103.345 29.3881 113.09L30.8431 115.403L24.6641 137.961L47.8101 131.892L50.0451 133.216C59.4321 138.787 70.1951 141.733 81.1711 141.739H81.1941C114.901 141.739 142.334 114.313 142.347 80.604C142.373 72.5696 140.804 64.6099 137.732 57.1858C134.661 49.7617 130.147 43.0207 124.452 37.353C118.789 31.6543 112.052 27.1354 104.631 24.0581C97.2092 20.9807 89.2512 19.406 81.2171 19.425Z"
fill="url(#paint0_linear_1006_58)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M62.8046 49.801C61.4266 46.74 59.9766 46.678 58.6676 46.625L55.1436 46.582C53.9176 46.582 51.9256 47.042 50.2416 48.882C48.5576 50.722 43.8066 55.169 43.8066 64.214C43.8066 73.259 50.3946 81.999 51.3126 83.227C52.2306 84.455 64.0306 103.608 82.7176 110.977C98.2466 117.101 101.407 115.883 104.779 115.577C108.151 115.271 115.656 111.13 117.187 106.837C118.718 102.544 118.719 98.866 118.26 98.097C117.801 97.328 116.575 96.871 114.735 95.951C112.895 95.031 103.858 90.584 102.173 89.97C100.488 89.356 99.2626 89.051 98.0356 90.891C96.8086 92.731 93.2896 96.87 92.2166 98.097C91.1436 99.324 90.0726 99.478 88.2326 98.559C86.3926 97.64 80.4726 95.698 73.4486 89.435C67.9836 84.562 64.2946 78.544 63.2206 76.705C62.1466 74.866 63.1066 73.87 64.0286 72.954C64.8536 72.13 65.8666 70.807 66.7876 69.734C67.7086 68.661 68.0116 67.894 68.6236 66.669C69.2356 65.444 68.9306 64.368 68.4706 63.449C68.0106 62.53 64.4386 53.437 62.8046 49.801Z"
fill="white"
/>
<defs>
<filter
id="filter0_f_1006_58"
x="0.691906"
y="0.69386"
width="161.897"
height="162.56"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="3.531"
result="effect1_foregroundBlur_1006_58"
/>
</filter>
<linearGradient
id="paint0_linear_1006_58"
x1="79.9481"
y1="26.765"
x2="80.5681"
y2="131.29"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#57D163" />
<stop offset="1" stopColor="#23B33A" />
</linearGradient>
</defs>
</Icon>
)

View File

@ -8,6 +8,7 @@ import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery'
import { useDebouncedCallback } from 'use-debounce'
import { env } from '@typebot.io/env'
import { identifyUser } from '../telemetry/posthog'
export const userContext = createContext<{
user?: User
@ -37,7 +38,11 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
)
const parsedUser = session.user as User
setUser(parsedUser)
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
if (parsedUser?.id) {
setSentryUser({ id: parsedUser.id })
identifyUser(parsedUser.id)
}
}, [session, user])
useEffect(() => {

View File

@ -0,0 +1,32 @@
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { ApiTokensList } from './ApiTokensList'
import { useUser } from '../hooks/useUser'
type Props = {
isOpen: boolean
onClose: () => void
}
export const ApiTokensModal = ({ isOpen, onClose }: Props) => {
const { user } = useUser()
if (!user) return
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader />
<ModalBody>
<ApiTokensList user={user} />
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -15,7 +15,7 @@ import {
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import React, { FormEvent, useState } from 'react'
import React, { FormEvent, useRef, useState } from 'react'
import { createApiTokenQuery } from '../queries/createApiTokenQuery'
import { ApiTokenFromServer } from '../types'
@ -32,6 +32,7 @@ export const CreateTokenModal = ({
onClose,
onNewToken,
}: Props) => {
const inputRef = useRef<HTMLInputElement>(null)
const scopedT = useScopedI18n('account.apiTokens.createModal')
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@ -47,8 +48,9 @@ export const CreateTokenModal = ({
}
setIsSubmitting(false)
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={inputRef}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
@ -58,7 +60,7 @@ export const CreateTokenModal = ({
{newTokenValue ? (
<ModalBody as={Stack} spacing="4">
<Text>
{scopedT('copyInstruction')}
{scopedT('copyInstruction')}{' '}
<strong>{scopedT('securityWarning')}</strong>
</Text>
<InputGroup size="md">
@ -72,6 +74,7 @@ export const CreateTokenModal = ({
<ModalBody as="form" onSubmit={createToken}>
<Text mb="4">{scopedT('nameInput.label')}</Text>
<Input
ref={inputRef}
placeholder={scopedT('nameInput.placeholder')}
onChange={(e) => setName(e.target.value)}
/>

View File

@ -120,6 +120,7 @@ export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
currentCredentialsId={options.credentialsId}
onCredentialsSelect={updateCredentials}
onCreateNewClick={onOpen}
credentialsName="Stripe account"
/>
)}
</Stack>

View File

@ -21,8 +21,7 @@ test.describe('Payment input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.getByRole('button', { name: 'Select an account' }).click()
await page.click('text=Connect new')
await page.getByRole('button', { name: 'Add Stripe account' }).click()
await page.fill('[placeholder="Typebot"]', 'My Stripe Account')
await page.fill('[placeholder="sk_test_..."]', env.STRIPE_SECRET_KEY ?? '')
await page.fill('[placeholder="sk_live_..."]', env.STRIPE_SECRET_KEY ?? '')

View File

@ -115,6 +115,7 @@ export const GoogleSheetsSettings = ({
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick}
credentialsName="Sheets account"
/>
)}
<GoogleSheetConnectModal

View File

@ -149,7 +149,7 @@ test.describe.parallel('Google sheets integration', () => {
const fillInSpreadsheetInfo = async (page: Page) => {
await page.click('text=Configure...')
await page.click('text=Select an account')
await page.click('text=Select Sheets account')
await page.click('text=pro-user@email.com')
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')

View File

@ -53,6 +53,7 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen}
credentialsName="OpenAI account"
/>
)}
<OpenAICredentialsModal

View File

@ -18,8 +18,7 @@ test('should be configurable', async ({ page }) => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.getByText('Configure...').click()
await page.getByRole('button', { name: 'Select an account' }).click()
await page.getByRole('menuitem', { name: 'Connect new' }).click()
await page.getByRole('button', { name: 'Add OpenAI account' }).click()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await page.getByPlaceholder('My account').fill('My account')
await page.getByPlaceholder('sk-...').fill('sk-test')

View File

@ -121,6 +121,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
defaultCredentialLabel={env.NEXT_PUBLIC_SMTP_FROM?.match(
/<(.*)>/
)?.pop()}
credentialsName="SMTP account"
/>
)}
</Stack>

View File

@ -69,5 +69,13 @@ const Expression = ({
</Text>
)
}
case 'Contact name':
case 'Phone number':
return (
<Text as="span">
{variableName} ={' '}
<Tag colorScheme="purple">WhatsApp.{options.type}</Tag>
</Text>
)
}
}

View File

@ -10,6 +10,7 @@ import React from 'react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { Select } from '@/components/inputs/Select'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
type Props = {
options: SetVariableOptions
@ -47,7 +48,14 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
</Text>
<Select
selectedItem={options.type ?? 'Custom'}
items={setVarTypes}
items={setVarTypes.map((type) => ({
label: type,
value: type,
icon:
type === 'Contact name' || type === 'Phone number' ? (
<WhatsAppLogo />
) : undefined,
}))}
onSelect={updateValueType}
/>
<SetVariableValue options={options} onOptionsChange={onOptionsChange} />
@ -150,6 +158,8 @@ const SetVariableValue = ({
</Alert>
)
}
case 'Contact name':
case 'Phone number':
case 'Random ID':
case 'Now':
case 'Today':

View File

@ -8,6 +8,9 @@ import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integ
import { encrypt } from '@typebot.io/lib/api/encryption'
import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
import { Credentials } from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib/utils'
const inputShape = {
data: true,
@ -33,6 +36,7 @@ export const createCredentials = authenticatedProcedure
smtpCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape),
whatsAppCredentialsSchema.pick(inputShape),
]),
})
)
@ -42,6 +46,11 @@ export const createCredentials = authenticatedProcedure
})
)
.mutation(async ({ input: { credentials }, ctx: { user } }) => {
if (await isNotAvailable(credentials.name, credentials.type))
throw new TRPCError({
code: 'CONFLICT',
message: 'Credentials already exist.',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: credentials.workspaceId,
@ -64,3 +73,14 @@ export const createCredentials = authenticatedProcedure
})
return { credentialsId: createdCredentials.id }
})
const isNotAvailable = async (name: string, type: Credentials['type']) => {
if (type !== 'whatsApp') return
const existingCredentials = await prisma.credentials.findFirst({
where: {
type,
name,
},
})
return isDefined(existingCredentials)
}

View File

@ -30,6 +30,9 @@ export const deleteCredentials = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: {
some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } },
},
},
select: { id: true, members: true },
})

View File

@ -7,6 +7,7 @@ import { openAICredentialsSchema } from '@typebot.io/schemas/features/blocks/int
import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
export const listCredentials = authenticatedProcedure
.meta({
@ -24,7 +25,8 @@ export const listCredentials = authenticatedProcedure
type: stripeCredentialsSchema.shape.type
.or(smtpCredentialsSchema.shape.type)
.or(googleSheetsCredentialsSchema.shape.type)
.or(openAICredentialsSchema.shape.type),
.or(openAICredentialsSchema.shape.type)
.or(whatsAppCredentialsSchema.shape.type),
})
)
.output(

View File

@ -1,9 +1,9 @@
import {
Button,
ButtonProps,
IconButton,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
Stack,
@ -16,13 +16,14 @@ import { useToast } from '../../../hooks/useToast'
import { Credentials } from '@typebot.io/schemas'
import { trpc } from '@/lib/trpc'
type Props = Omit<MenuButtonProps, 'type'> & {
type Props = Omit<ButtonProps, 'type'> & {
type: Credentials['type']
workspaceId: string
currentCredentialsId?: string
onCredentialsSelect: (credentialId?: string) => void
onCreateNewClick: () => void
defaultCredentialLabel?: string
credentialsName: string
}
export const CredentialsDropdown = ({
@ -32,6 +33,7 @@ export const CredentialsDropdown = ({
onCredentialsSelect,
onCreateNewClick,
defaultCredentialLabel,
credentialsName,
...props
}: Props) => {
const router = useRouter()
@ -59,7 +61,8 @@ export const CredentialsDropdown = ({
},
})
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
const defaultCredentialsLabel =
defaultCredentialLabel ?? `Select ${credentialsName}`
const currentCredential = data?.credentials.find(
(c) => c.id === currentCredentialsId
@ -97,6 +100,19 @@ export const CredentialsDropdown = ({
mutate({ workspaceId, credentialsId })
}
if (data?.credentials.length === 0 && !defaultCredentialLabel) {
return (
<Button
colorScheme="gray"
textAlign="left"
leftIcon={<PlusIcon />}
onClick={onCreateNewClick}
{...props}
>
Add {credentialsName}
</Button>
)
}
return (
<Menu isLazy>
<MenuButton
@ -107,7 +123,11 @@ export const CredentialsDropdown = ({
textAlign="left"
{...props}
>
<Text noOfLines={1} overflowY="visible" h="20px">
<Text
noOfLines={1}
overflowY="visible"
h={props.size === 'sm' ? '18px' : '20px'}
>
{currentCredential ? currentCredential.name : defaultCredentialsLabel}
</Text>
</MenuButton>

View File

@ -5,6 +5,7 @@ import { z } from 'zod'
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got, { HTTPError } from 'got'
import { env } from '@typebot.io/env'
export const createCustomDomain = authenticatedProcedure
.meta({
@ -79,7 +80,7 @@ export const createCustomDomain = authenticatedProcedure
const createDomainOnVercel = (name: string) =>
got.post({
url: `https://api.vercel.com/v10/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
url: `https://api.vercel.com/v10/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${env.VERCEL_TOKEN}` },
json: { name },
})

View File

@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got from 'got'
import { env } from '@typebot.io/env'
export const deleteCustomDomain = authenticatedProcedure
.meta({
@ -63,6 +64,6 @@ export const deleteCustomDomain = authenticatedProcedure
const deleteDomainOnVercel = (name: string) =>
got.delete({
url: `https://api.vercel.com/v9/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
url: `https://api.vercel.com/v9/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${env.VERCEL_TOKEN}` },
})

View File

@ -73,7 +73,7 @@ export const TypebotHeader = () => {
})
const handleHelpClick = () => {
isCloudProdInstance
isCloudProdInstance()
? onOpen()
: window.open('https://docs.typebot.io', '_blank')
}

View File

@ -38,6 +38,7 @@ type UpdateTypebotPayload = Partial<
| 'customDomain'
| 'resultsTablePreferences'
| 'isClosed'
| 'whatsAppPhoneNumberId'
>
>

View File

@ -0,0 +1,45 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got, { HTTPError } from 'got'
import { getViewerUrl } from '@typebot.io/lib'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
export const sendWhatsAppInitialMessage = authenticatedProcedure
.input(
z.object({
to: z.string(),
typebotId: z.string(),
startGroupId: z.string().optional(),
})
)
.mutation(
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
const apiToken = await prisma.apiToken.findFirst({
where: { ownerId: user.id },
})
if (!apiToken)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Api Token not found',
})
try {
await got.post({
method: 'POST',
url: `${getViewerUrl()}/api/v1/typebots/${typebotId}/whatsapp/start-preview`,
headers: {
Authorization: `Bearer ${apiToken.token}`,
},
json: { to, isPreview: true, startGroupId },
})
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to viewer failed',
cause: error instanceof HTTPError ? error.response.body : error,
})
}
return { message: 'success' }
}
)

View File

@ -44,7 +44,7 @@ export const ApiPreviewInstructions = (props: StackProps) => {
w="full"
{...props}
>
<OrderedList spacing={6}>
<OrderedList spacing={6} px="1">
<ListItem>
All your requests need to be authenticated with an API token.{' '}
<TextLink href="https://docs.typebot.io/api/builder/authenticate">
@ -93,7 +93,7 @@ export const ApiPreviewInstructions = (props: StackProps) => {
</Stack>
</ListItem>
</OrderedList>
<Text fontSize="sm">
<Text fontSize="sm" pl="1">
Check out the{' '}
<TextLink href="https://docs.typebot.io/api/send-a-message" isExternal>
API reference

View File

@ -18,8 +18,18 @@ import { PreviewDrawerBody } from './PreviewDrawerBody'
import { useDrag } from '@use-gesture/react'
import { ResizeHandle } from './ResizeHandle'
const preferredRuntimeKey = 'preferredRuntime'
const getDefaultRuntime = (typebotId?: string) => {
if (!typebotId) return runtimes[0]
const preferredRuntime = localStorage.getItem(preferredRuntimeKey)
return (
runtimes.find((runtime) => runtime.name === preferredRuntime) ?? runtimes[0]
)
}
export const PreviewDrawer = () => {
const { save, isSavingLoading } = useTypebot()
const { typebot, save, isSavingLoading } = useTypebot()
const { setRightPanel } = useEditor()
const { setPreviewingBlock } = useGraph()
const [width, setWidth] = useState(500)
@ -27,7 +37,7 @@ export const PreviewDrawer = () => {
const [restartKey, setRestartKey] = useState(0)
const [selectedRuntime, setSelectedRuntime] = useState<
(typeof runtimes)[number]
>(runtimes[0])
>(getDefaultRuntime(typebot?.id))
const handleRestartClick = async () => {
await save()
@ -48,6 +58,13 @@ export const PreviewDrawer = () => {
}
)
const setPreviewRuntimeAndSaveIntoLocalStorage = (
runtime: (typeof runtimes)[number]
) => {
setSelectedRuntime(runtime)
localStorage.setItem(preferredRuntimeKey, runtime.name)
}
return (
<Flex
pos="absolute"
@ -78,7 +95,7 @@ export const PreviewDrawer = () => {
<HStack>
<RuntimeMenu
selectedRuntime={selectedRuntime}
onSelectRuntime={(runtime) => setSelectedRuntime(runtime)}
onSelectRuntime={setPreviewRuntimeAndSaveIntoLocalStorage}
/>
{selectedRuntime.name === 'Web' ? (
<Button

View File

@ -1,16 +1,20 @@
import { runtimes } from '../data'
import { ApiPreviewInstructions } from './ApiPreviewInstructions'
import { WebPreview } from './WebPreview'
import { WhatsAppPreviewInstructions } from './WhatsAppPreviewInstructions'
type Props = {
runtime: (typeof runtimes)[number]['name']
}
export const PreviewDrawerBody = ({ runtime }: Props) => {
export const PreviewDrawerBody = ({ runtime }: Props): JSX.Element => {
switch (runtime) {
case 'Web': {
return <WebPreview />
}
case 'WhatsApp': {
return <WhatsAppPreviewInstructions />
}
case 'API': {
return <ApiPreviewInstructions pt="4" />
}

View File

@ -10,6 +10,7 @@ import {
Text,
} from '@chakra-ui/react'
import { runtimes } from '../data'
import { getFeatureFlags } from '@/features/telemetry/posthog'
type Runtime = (typeof runtimes)[number]
@ -18,37 +19,44 @@ type Props = {
onSelectRuntime: (runtime: Runtime) => void
}
export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => (
<Menu>
<MenuButton
as={Button}
leftIcon={selectedRuntime.icon}
rightIcon={<ChevronDownIcon />}
>
<HStack justifyContent="space-between">
<Text>{selectedRuntime.name}</Text>
{'status' in selectedRuntime ? (
<Tag colorScheme="orange">{selectedRuntime.status}</Tag>
) : null}
</HStack>
</MenuButton>
<MenuList w="100px">
{runtimes
.filter((runtime) => runtime.name !== selectedRuntime.name)
.map((runtime) => (
<MenuItem
key={runtime.name}
icon={runtime.icon}
onClick={() => onSelectRuntime(runtime)}
>
<HStack justifyContent="space-between">
<Text>{runtime.name}</Text>
{'status' in runtime ? (
<Tag colorScheme="orange">{runtime.status}</Tag>
) : null}
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
)
export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => {
return (
<Menu>
<MenuButton
as={Button}
leftIcon={selectedRuntime.icon}
rightIcon={<ChevronDownIcon />}
>
<HStack justifyContent="space-between">
<Text>{selectedRuntime.name}</Text>
{'status' in selectedRuntime ? (
<Tag colorScheme="orange">{selectedRuntime.status}</Tag>
) : null}
</HStack>
</MenuButton>
<MenuList w="100px">
{runtimes
.filter((runtime) => runtime.name !== selectedRuntime.name)
.filter((runtime) =>
runtime.name === 'WhatsApp'
? getFeatureFlags().includes('whatsApp')
: true
)
.map((runtime) => (
<MenuItem
key={runtime.name}
icon={runtime.icon}
onClick={() => onSelectRuntime(runtime)}
>
<HStack justifyContent="space-between">
<Text>{runtime.name}</Text>
{'status' in runtime ? (
<Tag colorScheme="orange">{runtime.status}</Tag>
) : null}
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
)
}

View File

@ -0,0 +1,112 @@
import { TextInput } from '@/components/inputs'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Alert,
AlertIcon,
Button,
Flex,
HStack,
SlideFade,
Stack,
StackProps,
Text,
} from '@chakra-ui/react'
import { isEmpty } from '@typebot.io/lib'
import { FormEvent, useState } from 'react'
import {
getPhoneNumberFromLocalStorage,
setPhoneNumberInLocalStorage,
} from '../helpers/phoneNumberFromLocalStorage'
import { useEditor } from '@/features/editor/providers/EditorProvider'
export const WhatsAppPreviewInstructions = (props: StackProps) => {
const { typebot, save } = useTypebot()
const { startPreviewAtGroup } = useEditor()
const [phoneNumber, setPhoneNumber] = useState(
getPhoneNumberFromLocalStorage() ?? ''
)
const [isSendingMessage, setIsSendingMessage] = useState(false)
const [isMessageSent, setIsMessageSent] = useState(false)
const [hasMessageBeenSent, setHasMessageBeenSent] = useState(false)
const { showToast } = useToast()
const { mutate } = trpc.sendWhatsAppInitialMessage.useMutation({
onMutate: () => setIsSendingMessage(true),
onSettled: () => setIsSendingMessage(false),
onError: (error) => showToast({ description: error.message }),
onSuccess: async (data) => {
if (
data?.message === 'success' &&
phoneNumber !== getPhoneNumberFromLocalStorage()
)
setPhoneNumberInLocalStorage(phoneNumber)
setHasMessageBeenSent(true)
setIsMessageSent(true)
setTimeout(() => setIsMessageSent(false), 30000)
},
})
const sendWhatsAppPreviewStartMessage = async (e: FormEvent) => {
e.preventDefault()
if (!typebot) return
await save()
mutate({
to: phoneNumber,
typebotId: typebot.id,
startGroupId: startPreviewAtGroup,
})
}
return (
<Stack
as="form"
spacing={4}
overflowY="scroll"
className="hide-scrollbar"
w="full"
px="1"
onSubmit={sendWhatsAppPreviewStartMessage}
{...props}
>
<Alert status="warning">
<AlertIcon />
The WhatsApp integration is still experimental.
<br />I appreciate your bug reports 🧡
</Alert>
<TextInput
label="Your phone number"
placeholder="+XXXXXXXXXXXX"
type="tel"
withVariableButton={false}
debounceTimeout={0}
defaultValue={phoneNumber}
onChange={setPhoneNumber}
/>
<Button
isDisabled={isEmpty(phoneNumber) || isMessageSent}
isLoading={isSendingMessage}
type="submit"
>
{hasMessageBeenSent ? 'Restart' : 'Start'} the chat
</Button>
<SlideFade offsetY="20px" in={isMessageSent} unmountOnExit>
<Flex>
<Alert status="success" w="100%">
<HStack>
<AlertIcon />
<Stack spacing={1}>
<Text fontWeight="semibold">Chat started!</Text>
<Text fontSize="sm">
Open WhatsApp to test your bot. The first message can take up
to 2 min to be delivered.
</Text>
</Stack>
</HStack>
</Alert>
</Flex>
</SlideFade>
</Stack>
)
}

View File

@ -1,9 +1,15 @@
import { GlobeIcon, CodeIcon } from '@/components/icons'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
export const runtimes = [
{
name: 'Web',
icon: <GlobeIcon />,
},
{ name: 'API', icon: <CodeIcon />, status: 'beta' },
{
name: 'WhatsApp',
icon: <WhatsAppLogo />,
status: 'beta',
},
{ name: 'API', icon: <CodeIcon /> },
] as const

View File

@ -0,0 +1,8 @@
export const phoneNumberKey = 'whatsapp-phone'
export const getPhoneNumberFromLocalStorage = () =>
localStorage.getItem(phoneNumberKey)
export const setPhoneNumberInLocalStorage = (phoneNumber: string) => {
localStorage.setItem(phoneNumberKey, phoneNumber)
}

View File

@ -32,11 +32,17 @@ import { trpc } from '@/lib/trpc'
import { useToast } from '@/hooks/useToast'
import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
export const PublishButton = (props: ButtonProps) => {
type Props = ButtonProps & {
isMoreMenuDisabled?: boolean
}
export const PublishButton = ({
isMoreMenuDisabled = false,
...props
}: Props) => {
const t = useI18n()
const warningTextColor = useColorModeValue('red.300', 'red.600')
const { workspace } = useWorkspace()
const { push, query } = useRouter()
const { push, query, pathname } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
const {
isPublished,
@ -66,7 +72,8 @@ export const PublishButton = (props: ButtonProps) => {
refetchPublishedTypebot({
typebotId: typebot?.id as string,
})
if (!publishedTypebot) push(`/typebots/${query.typebotId}/share`)
if (!publishedTypebot && !pathname.endsWith('share'))
push(`/typebots/${query.typebotId}/share`)
},
})
@ -153,7 +160,9 @@ export const PublishButton = (props: ButtonProps) => {
isLoading={isPublishing || isUnpublishing}
isDisabled={isPublished || isSavingLoading}
onClick={handlePublishClick}
borderRightRadius={publishedTypebot ? 0 : undefined}
borderRightRadius={
publishedTypebot && !isMoreMenuDisabled ? 0 : undefined
}
{...props}
>
{isPublished
@ -164,7 +173,7 @@ export const PublishButton = (props: ButtonProps) => {
</Button>
</Tooltip>
{publishedTypebot && (
{!isMoreMenuDisabled && publishedTypebot && (
<Menu>
<MenuButton
as={IconButton}

View File

@ -68,7 +68,7 @@ export const SharePage = () => {
const checkIfPublicIdIsValid = async (publicId: string) => {
const isLongerThanAllowed = publicId.length >= 4
if (!isLongerThanAllowed && isCloudProdInstance) {
if (!isLongerThanAllowed && isCloudProdInstance()) {
showToast({
description: 'Should be longer than 4 characters',
})

View File

@ -39,6 +39,10 @@ import { FlutterFlowLogo } from './logos/FlutterFlowLogo'
import { FlutterFlowModal } from './modals/FlutterFlowModal'
import { NextjsLogo } from './logos/NextjsLogo'
import { NextjsModal } from './modals/Nextjs/NextjsModal'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
import { WhatsAppModal } from './modals/WhatsAppModal/WhatsAppModal'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { getFeatureFlags } from '@/features/telemetry/posthog'
export type ModalProps = {
publicId: string
@ -79,6 +83,19 @@ export const EmbedButton = ({
}
export const integrationsList = [
(props: Pick<ModalProps, 'publicId' | 'isPublished'>) => {
if (getFeatureFlags().includes('whatsApp'))
return (
<ParentModalProvider>
<EmbedButton
logo={<WhatsAppLogo height={100} width="70px" />}
label="WhatsApp"
Modal={WhatsAppModal}
{...props}
/>
</ParentModalProvider>
)
},
(props: Pick<ModalProps, 'publicId' | 'isPublished'>) => (
<EmbedButton
logo={<WordpressLogo height={100} width="70px" />}

View File

@ -0,0 +1,70 @@
import { HStack, Text } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { ComparisonOperators } from '@typebot.io/schemas'
import { TableListItemProps } from '@/components/TableList'
import { TextInput } from '@/components/inputs'
import { WhatsAppComparison } from '@typebot.io/schemas/features/whatsapp'
export const WhatsAppComparisonItem = ({
item,
onItemChange,
}: TableListItemProps<WhatsAppComparison>) => {
const handleSelectComparisonOperator = (
comparisonOperator: ComparisonOperators
) => {
if (comparisonOperator === item.comparisonOperator) return
onItemChange({ ...item, comparisonOperator })
}
const handleChangeValue = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<HStack p="4" rounded="md" flex="1" borderWidth="1px">
<Text flexShrink={0}>User message</Text>
<DropdownList
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}
placeholder="Select an operator"
size="sm"
flexShrink={0}
/>
{item.comparisonOperator !== ComparisonOperators.IS_SET &&
item.comparisonOperator !== ComparisonOperators.IS_EMPTY && (
<TextInput
defaultValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder={parseValuePlaceholder(item.comparisonOperator)}
withVariableButton={false}
size="sm"
/>
)}
</HStack>
)
}
const parseValuePlaceholder = (
operator: ComparisonOperators | undefined
): string => {
switch (operator) {
case ComparisonOperators.NOT_EQUAL:
case ComparisonOperators.EQUAL:
case ComparisonOperators.CONTAINS:
case ComparisonOperators.STARTS_WITH:
case ComparisonOperators.ENDS_WITH:
case ComparisonOperators.NOT_CONTAINS:
case undefined:
return 'Type a value...'
case ComparisonOperators.LESS:
case ComparisonOperators.GREATER:
return 'Type a number...'
case ComparisonOperators.IS_SET:
case ComparisonOperators.IS_EMPTY:
return ''
case ComparisonOperators.MATCHES_REGEX:
case ComparisonOperators.NOT_MATCH_REGEX:
return '^[0-9]+$'
}
}

View File

@ -0,0 +1,502 @@
import { CopyButton } from '@/components/CopyButton'
import { TextLink } from '@/components/TextLink'
import { ChevronLeftIcon, ExternalLinkIcon } from '@/components/icons'
import { TextInput } from '@/components/inputs/TextInput'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { trpc, trpcVanilla } from '@/lib/trpc'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Stepper,
useSteps,
Step,
StepIndicator,
Box,
StepIcon,
StepNumber,
StepSeparator,
StepStatus,
StepTitle,
UnorderedList,
ListItem,
Text,
Image,
Button,
HStack,
IconButton,
Heading,
OrderedList,
Link,
Code,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import { env } from '@typebot.io/env'
import { getViewerUrl, isEmpty, isNotEmpty } from '@typebot.io/lib/utils'
import React, { useState } from 'react'
const steps = [
{ title: 'Requirements' },
{ title: 'User Token' },
{ title: 'Phone Number' },
{ title: 'Webhook' },
]
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const WhatsAppCredentialsModal = ({
isOpen,
onClose,
onNewCredentials,
}: Props) => {
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { activeStep, goToNext, goToPrevious, setActiveStep } = useSteps({
index: 0,
count: steps.length,
})
const [systemUserAccessToken, setSystemUserAccessToken] = useState('')
const [phoneNumberId, setPhoneNumberId] = useState('')
const [phoneNumberName, setPhoneNumberName] = useState('')
const [verificationToken, setVerificationToken] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.credentials.createCredentials.useMutation({
onMutate: () => setIsCreating(true),
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
resetForm()
},
})
const { data: tokenInfoData } = trpc.whatsApp.getSystemTokenInfo.useQuery(
{
token: systemUserAccessToken,
},
{ enabled: isNotEmpty(systemUserAccessToken) }
)
const resetForm = () => {
setActiveStep(0)
setSystemUserAccessToken('')
setPhoneNumberId('')
}
const createMetaCredentials = async () => {
if (!workspace) return
mutate({
credentials: {
type: 'whatsApp',
workspaceId: workspace.id,
name: phoneNumberName,
data: {
systemUserAccessToken,
phoneNumberId,
},
},
})
}
const isTokenValid = async () => {
setIsVerifying(true)
try {
const { expiresAt, scopes } =
await trpcVanilla.whatsApp.getSystemTokenInfo.query({
token: systemUserAccessToken,
})
if (expiresAt !== 0) {
showToast({
description:
'Token expiration was not set to *never*. Create the token again with the correct expiration.',
})
return false
}
if (
['whatsapp_business_management', 'whatsapp_business_messaging'].find(
(scope) => !scopes.includes(scope)
)
) {
showToast({
description: 'Token does not have all the necessary scopes',
})
return false
}
} catch (err) {
setIsVerifying(false)
showToast({
description: 'Could not get system info',
})
return false
}
setIsVerifying(false)
return true
}
const isPhoneNumberAvailable = async () => {
setIsVerifying(true)
try {
const { name } = await trpcVanilla.whatsApp.getPhoneNumber.query({
systemToken: systemUserAccessToken,
phoneNumberId,
})
setPhoneNumberName(name)
try {
const { message } =
await trpcVanilla.whatsApp.verifyIfPhoneNumberAvailable.query({
phoneNumberDisplayName: name,
})
if (message === 'taken') {
setIsVerifying(false)
showToast({
description: 'Phone number is already registered on Typebot',
})
return false
}
const { verificationToken } =
await trpcVanilla.whatsApp.generateVerificationToken.mutate()
setVerificationToken(verificationToken)
} catch (err) {
console.error(err)
setIsVerifying(false)
showToast({
description: 'Could not verify if phone number is available',
})
return false
}
} catch (err) {
console.error(err)
setIsVerifying(false)
showToast({
description: 'Could not get phone number info',
})
return false
}
setIsVerifying(false)
return true
}
const goToNextStep = async () => {
if (activeStep === steps.length - 1) return createMetaCredentials()
if (activeStep === 1 && !(await isTokenValid())) return
if (activeStep === 2 && !(await isPhoneNumberAvailable())) return
goToNext()
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack h="40px">
{activeStep > 0 && (
<IconButton
icon={<ChevronLeftIcon />}
aria-label={'Go back'}
variant="ghost"
onClick={goToPrevious}
/>
)}
<Heading size="md">Add a WhatsApp phone number</Heading>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="10">
<Stepper index={activeStep} size="sm" pt="4">
{steps.map((step, index) => (
<Step key={index}>
<StepIndicator>
<StepStatus
complete={<StepIcon />}
incomplete={<StepNumber />}
active={<StepNumber />}
/>
</StepIndicator>
<Box flexShrink="0">
<StepTitle>{step.title}</StepTitle>
</Box>
<StepSeparator />
</Step>
))}
</Stepper>
{activeStep === 0 && <Requirements />}
{activeStep === 1 && (
<SystemUserToken
initialToken={systemUserAccessToken}
setToken={setSystemUserAccessToken}
/>
)}
{activeStep === 2 && (
<PhoneNumber
appId={tokenInfoData?.appId}
initialPhoneNumberId={phoneNumberId}
setPhoneNumberId={setPhoneNumberId}
/>
)}
{activeStep === 3 && (
<Webhook
appId={tokenInfoData?.appId}
verificationToken={verificationToken}
phoneNumberId={phoneNumberId}
/>
)}
</ModalBody>
<ModalFooter>
<Button
onClick={goToNextStep}
colorScheme="blue"
isDisabled={
(activeStep === 1 && isEmpty(systemUserAccessToken)) ||
(activeStep === 2 && isEmpty(phoneNumberId))
}
isLoading={isVerifying || isCreating}
>
{activeStep === steps.length - 1 ? 'Submit' : 'Continue'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const Requirements = () => (
<Stack spacing={4}>
<Text>
Make sure you have{' '}
<TextLink
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#set-up-developer-assets"
isExternal
>
created a WhatsApp Business Account
</TextLink>
. You should be able to get to this page:
</Text>
<Image
src="/images/whatsapp-quickstart-page.png"
alt="WhatsApp quickstart page"
rounded="md"
/>
<Text>
You can find your Meta apps here:{' '}
<TextLink href="https://developers.facebook.com/apps" isExternal>
https://developers.facebook.com/apps
</TextLink>
.
</Text>
</Stack>
)
const SystemUserToken = ({
initialToken,
setToken,
}: {
initialToken: string
setToken: (id: string) => void
}) => (
<OrderedList spacing={4}>
<ListItem>
Go to your{' '}
<Button
as={Link}
href="https://business.facebook.com/settings/system-users"
isExternal
rightIcon={<ExternalLinkIcon />}
size="sm"
>
System users page
</Button>
</ListItem>
<ListItem>
Create a new user by clicking on <Code>Add</Code>
</ListItem>
<ListItem>
Fill it with any name and give it the <Code>Admin</Code> role
</ListItem>
<ListItem>
<Stack>
<Text>
Click on <Code>Add assets</Code>. Under <Code>Apps</Code>, look for
your previously created app, select it and check{' '}
<Code>Manage app</Code>
</Text>
<Image
src="/images/meta-system-user-assets.png"
alt="Meta system user assets"
rounded="md"
/>
</Stack>
</ListItem>
<ListItem>
<Stack spacing={4}>
<Text>
Now, click on <Code>Generate new token</Code>. Select your app.
</Text>
<UnorderedList spacing={4}>
<ListItem>
Token expiration: <Code>Never</Code>
</ListItem>
<ListItem>
Available Permissions: <Code>whatsapp_business_messaging</Code>,{' '}
<Code>whatsapp_business_management</Code>{' '}
</ListItem>
</UnorderedList>
</Stack>
</ListItem>
<ListItem>Copy and paste the generated token:</ListItem>
<TextInput
isRequired
label="System User Token"
defaultValue={initialToken}
onChange={(val) => setToken(val.trim())}
withVariableButton={false}
debounceTimeout={0}
/>
</OrderedList>
)
const PhoneNumber = ({
appId,
initialPhoneNumberId,
setPhoneNumberId,
}: {
appId?: string
initialPhoneNumberId: string
setPhoneNumberId: (id: string) => void
}) => (
<OrderedList spacing={4}>
<ListItem>
<HStack>
<Text>
Go to your{' '}
<TextLink
href={`https://developers.facebook.com/apps/${appId}/whatsapp-business/wa-dev-console`}
isExternal
>
WhatsApp Dev Console
</TextLink>
</Text>
</HStack>
</ListItem>
<ListItem>
Add your phone number by clicking on the <Code>Add phone number</Code>{' '}
button.
</ListItem>
<ListItem>
<Stack>
<Text>
Select a phone number and paste the associated{' '}
<Code>Phone number ID</Code> and{' '}
<Code>WhatsApp Business Account ID</Code>:
</Text>
<HStack>
<TextInput
label="Phone number ID"
defaultValue={initialPhoneNumberId}
withVariableButton={false}
debounceTimeout={0}
isRequired
onChange={setPhoneNumberId}
/>
</HStack>
<Image
src="/images/whatsapp-phone-selection.png"
alt="WA phone selection"
/>
</Stack>
</ListItem>
</OrderedList>
)
const Webhook = ({
appId,
verificationToken,
phoneNumberId,
}: {
appId?: string
verificationToken: string
phoneNumberId: string
}) => {
const { workspace } = useWorkspace()
const webhookUrl = `${
env.NEXT_PUBLIC_VIEWER_INTERNAL_URL ?? getViewerUrl()
}/api/v1/workspaces/${
workspace?.id
}/whatsapp/phoneNumbers/${phoneNumberId}/webhook`
return (
<Stack spacing={6}>
<Text>
In your{' '}
<TextLink
href={`https://developers.facebook.com/apps/${appId}/whatsapp-business/wa-settings`}
isExternal
>
WhatsApp Settings page
</TextLink>
, click on the Edit button and insert the following values:
</Text>
<UnorderedList spacing={6}>
<ListItem>
<HStack>
<Text flexShrink={0}>Callback URL:</Text>
<InputGroup size="sm">
<Input type={'text'} defaultValue={webhookUrl} />
<InputRightElement width="60px">
<CopyButton size="sm" textToCopy={webhookUrl} />
</InputRightElement>
</InputGroup>
</HStack>
</ListItem>
<ListItem>
<HStack>
<Text flexShrink={0}>Verify Token:</Text>
<InputGroup size="sm">
<Input type={'text'} defaultValue={verificationToken} />
<InputRightElement width="60px">
<CopyButton size="sm" textToCopy={verificationToken} />
</InputRightElement>
</InputGroup>
</HStack>
</ListItem>
<ListItem>
<HStack>
<Text flexShrink={0}>
Webhook fields: check <Code>messages</Code>
</Text>
</HStack>
</ListItem>
</UnorderedList>
</Stack>
)
}

View File

@ -0,0 +1,233 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
Heading,
ModalCloseButton,
ModalBody,
ModalFooter,
Stack,
Text,
OrderedList,
ListItem,
HStack,
useDisclosure,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Flex,
} from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { CredentialsDropdown } from '@/features/credentials/components/CredentialsDropdown'
import { ModalProps } from '../../EmbedButton'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { WhatsAppCredentialsModal } from './WhatsAppCredentialsModal'
import { TextLink } from '@/components/TextLink'
import { PublishButton } from '../../../PublishButton'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { isDefined } from '@typebot.io/lib/utils'
import { TableList } from '@/components/TableList'
import { Comparison, LogicalOperator } from '@typebot.io/schemas'
import { DropdownList } from '@/components/DropdownList'
import { WhatsAppComparisonItem } from './WhatsAppComparisonItem'
import { AlertInfo } from '@/components/AlertInfo'
export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
const { typebot, updateTypebot, isPublished } = useTypebot()
const { ref } = useParentModal()
const { workspace } = useWorkspace()
const {
isOpen: isCredentialsModalOpen,
onOpen,
onClose: onCredentialsModalClose,
} = useDisclosure()
const whatsAppSettings = typebot?.settings.whatsApp
const { data: phoneNumberData } = trpc.whatsApp.getPhoneNumber.useQuery(
{
credentialsId: whatsAppSettings?.credentialsId as string,
},
{
enabled: !!whatsAppSettings?.credentialsId,
}
)
const toggleEnableWhatsApp = (isChecked: boolean) => {
if (!phoneNumberData?.id) return
updateTypebot({
updates: { whatsAppPhoneNumberId: isChecked ? phoneNumberData.id : null },
save: true,
})
}
const updateCredentialsId = (credentialsId: string | undefined) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
credentialsId,
},
},
},
})
}
const updateStartConditionComparisons = (comparisons: Comparison[]) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
startCondition: {
logicalOperator:
typebot.settings.whatsApp?.startCondition?.logicalOperator ??
LogicalOperator.AND,
comparisons,
},
},
},
},
})
}
const updateStartConditionLogicalOperator = (
logicalOperator: LogicalOperator
) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
startCondition: {
comparisons:
typebot.settings.whatsApp?.startCondition?.comparisons ?? [],
logicalOperator,
},
},
},
},
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent ref={ref}>
<ModalHeader>
<Heading size="md">WhatsApp</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
{!isPublished && phoneNumberData?.id && (
<AlertInfo>You have modifications that can be published.</AlertInfo>
)}
<OrderedList spacing={4} pl="4">
<ListItem>
<HStack>
<Text>Select a phone number:</Text>
{workspace && (
<>
<WhatsAppCredentialsModal
isOpen={isCredentialsModalOpen}
onClose={onCredentialsModalClose}
onNewCredentials={updateCredentialsId}
/>
<CredentialsDropdown
type="whatsApp"
workspaceId={workspace.id}
currentCredentialsId={whatsAppSettings?.credentialsId}
onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen}
credentialsName="WA phone number"
size="sm"
/>
</>
)}
</HStack>
</ListItem>
{typebot?.settings.whatsApp?.credentialsId && (
<>
<ListItem>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Start flow only if
<AccordionIcon />
</AccordionButton>
<AccordionPanel as={Stack} spacing="4" pt="4">
<TableList<Comparison>
initialItems={
whatsAppSettings?.startCondition?.comparisons ?? []
}
onItemsChange={updateStartConditionComparisons}
Item={WhatsAppComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList
currentItem={
whatsAppSettings?.startCondition
?.logicalOperator
}
onItemSelect={
updateStartConditionLogicalOperator
}
items={Object.values(LogicalOperator)}
size="sm"
/>
</Flex>
)}
addLabel="Add a comparison"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</ListItem>
<ListItem>
<HStack>
<Text>Publish your bot:</Text>
<PublishButton size="sm" isMoreMenuDisabled />
</HStack>
</ListItem>
<ListItem>
<SwitchWithLabel
label="Enable WhatsApp integration"
initialValue={
isDefined(typebot?.whatsAppPhoneNumberId) ? true : false
}
onCheckChange={toggleEnableWhatsApp}
justifyContent="flex-start"
/>
</ListItem>
{phoneNumberData?.id && (
<ListItem>
<TextLink
href={`https://wa.me/${phoneNumberData.name}?text=Start`}
isExternal
>
Try it out
</TextLink>
</ListItem>
)}
</>
)}
</OrderedList>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -76,7 +76,7 @@ const parseWordpressShortcode = ({
publicId: string
}) => {
return `[typebot typebot="${publicId}"${
isCloudProdInstance ? '' : ` host="${getViewerUrl()}"`
isCloudProdInstance() ? '' : ` host="${getViewerUrl()}"`
}${width ? ` width="${width}"` : ''}${height ? ` height="${height}"` : ''}]
`
}

View File

@ -41,7 +41,7 @@ export const parseReactBotProps = ({ typebot, apiHost }: BotProps) => {
return `${typebotLine} ${apiHostLine}`
}
export const typebotImportCode = isCloudProdInstance
export const typebotImportCode = isCloudProdInstance()
? `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.1/dist/web.js'`
: `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@${packageJson.version}/dist/web.js'`
@ -64,6 +64,6 @@ export const parseApiHost = (
export const parseApiHostValue = (
customDomain: Typebot['customDomain'] | undefined
) => {
if (isCloudProdInstance) return
if (isCloudProdInstance()) return
return parseApiHost(customDomain)
}

View File

@ -23,4 +23,5 @@ export const convertPublicTypebotToTypebot = (
isClosed: existingTypebot.isClosed,
resultsTablePreferences: existingTypebot.resultsTablePreferences,
selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId,
whatsAppPhoneNumberId: existingTypebot.whatsAppPhoneNumberId,
})

View File

@ -14,6 +14,7 @@ export const processTelemetryEvent = authenticatedProcedure
path: '/t/process',
description:
"Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services.",
tags: ['Telemetry'],
},
})
.input(
@ -26,19 +27,19 @@ export const processTelemetryEvent = authenticatedProcedure
message: z.literal('Events injected'),
})
)
.query(async ({ input: { events }, ctx: { user } }) => {
.mutation(async ({ input: { events }, ctx: { user } }) => {
if (user.email !== env.ADMIN_EMAIL)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only app admin can process telemetry events',
})
if (!env.POSTHOG_API_KEY)
if (!env.NEXT_PUBLIC_POSTHOG_KEY)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Server does not have POSTHOG_API_KEY configured',
})
const client = new PostHog(env.POSTHOG_API_KEY, {
host: 'https://eu.posthog.com',
const client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
})
events.forEach(async (event) => {

View File

@ -0,0 +1,34 @@
import { env } from '@typebot.io/env'
import posthog from 'posthog-js'
export const initPostHogIfEnabled = () => {
if (typeof window === 'undefined') return
const posthogKey = env.NEXT_PUBLIC_POSTHOG_KEY
if (!posthogKey) return
posthog.init(posthogKey, {
api_host: env.NEXT_PUBLIC_POSTHOG_HOST,
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') posthog.debug()
},
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
})
}
export const identifyUser = (userId: string) => {
if (!posthog.__loaded) return
posthog.identify(userId)
}
export const getFeatureFlags = () => {
return posthog.__loaded &&
posthog.isFeatureEnabled('whatsApp', { send_event: false })
? ['whatsApp']
: []
}
export { posthog }

View File

@ -30,6 +30,7 @@ export const updateTypebot = authenticatedProcedure
typebotSchema._def.schema
.pick({
isClosed: true,
whatsAppPhoneNumberId: true,
})
.partial()
),
@ -68,6 +69,7 @@ export const updateTypebot = authenticatedProcedure
plan: true,
},
},
whatsAppPhoneNumberId: true,
updatedAt: true,
},
})
@ -101,7 +103,7 @@ export const updateTypebot = authenticatedProcedure
})
if (typebot.publicId) {
if (isCloudProdInstance && typebot.publicId.length < 4)
if (isCloudProdInstance() && typebot.publicId.length < 4)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Public id should be at least 4 characters long',
@ -148,6 +150,7 @@ export const updateTypebot = authenticatedProcedure
customDomain:
typebot.customDomain === null ? null : typebot.customDomain,
isClosed: typebot.isClosed,
whatsAppPhoneNumberId: typebot.whatsAppPhoneNumberId ?? undefined,
},
})

View File

@ -0,0 +1,31 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import prisma from '@/lib/prisma'
import { createId } from '@paralleldrive/cuid2'
export const generateVerificationToken = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/verficiationTokens',
protect: true,
},
})
.input(z.void())
.output(
z.object({
verificationToken: z.string(),
})
)
.mutation(async () => {
const oneHourLater = new Date(Date.now() + 1000 * 60 * 60)
const verificationToken = await prisma.verificationToken.create({
data: {
token: createId(),
expires: oneHourLater,
identifier: 'whatsapp webhook',
},
})
return { verificationToken: verificationToken.token }
})

View File

@ -0,0 +1,78 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got from 'got'
import prisma from '@/lib/prisma'
import { decrypt } from '@typebot.io/lib/api'
import { TRPCError } from '@trpc/server'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import { parsePhoneNumber } from 'libphonenumber-js'
const inputSchema = z.object({
credentialsId: z.string().optional(),
systemToken: z.string().optional(),
phoneNumberId: z.string().optional(),
})
export const getPhoneNumber = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/phoneNumber',
protect: true,
},
})
.input(inputSchema)
.output(
z.object({
id: z.string(),
name: z.string(),
})
)
.query(async ({ input, ctx: { user } }) => {
const credentials = await getCredentials(user.id, input)
if (!credentials)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const { display_phone_number } = (await got(
`https://graph.facebook.com/v17.0/${credentials.phoneNumberId}`,
{
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
}
).json()) as {
display_phone_number: string
}
return {
id: credentials.phoneNumberId,
name: parsePhoneNumber(display_phone_number)
.formatInternational()
.replace(/\s/g, ''),
}
})
const getCredentials = async (
userId: string,
input: z.infer<typeof inputSchema>
): Promise<WhatsAppCredentials['data'] | undefined> => {
if (input.systemToken && input.phoneNumberId)
return {
systemUserAccessToken: input.systemToken,
phoneNumberId: input.phoneNumberId,
}
if (!input.credentialsId) return
const credentials = await prisma.credentials.findUnique({
where: {
id: input.credentialsId,
workspace: { members: { some: { userId } } },
},
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
}

View File

@ -0,0 +1,88 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got from 'got'
import { TRPCError } from '@trpc/server'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import prisma from '@/lib/prisma'
import { decrypt } from '@typebot.io/lib/api/encryption'
const inputSchema = z.object({
token: z.string().optional(),
credentialsId: z.string().optional(),
})
export const getSystemTokenInfo = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/systemToken',
protect: true,
},
})
.input(inputSchema)
.output(
z.object({
appId: z.string(),
appName: z.string(),
expiresAt: z.number(),
scopes: z.array(z.string()),
})
)
.query(async ({ input, ctx: { user } }) => {
if (!input.token && !input.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either token or credentialsId must be provided',
})
const credentials = await getCredentials(user.id, input)
if (!credentials)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const {
data: { expires_at, scopes, app_id, application },
} = (await got(
`https://graph.facebook.com/v17.0/debug_token?input_token=${credentials.systemUserAccessToken}`,
{
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
}
).json()) as {
data: {
app_id: string
application: string
expires_at: number
scopes: string[]
}
}
return {
appId: app_id,
appName: application,
expiresAt: expires_at,
scopes,
}
})
const getCredentials = async (
userId: string,
input: z.infer<typeof inputSchema>
): Promise<Omit<WhatsAppCredentials['data'], 'phoneNumberId'> | undefined> => {
if (input.token)
return {
systemUserAccessToken: input.token,
}
const credentials = await prisma.credentials.findUnique({
where: {
id: input.credentialsId,
workspace: { members: { some: { userId } } },
},
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
}

View File

@ -0,0 +1,12 @@
import { router } from '@/helpers/server/trpc'
import { getPhoneNumber } from './getPhoneNumber'
import { getSystemTokenInfo } from './getSystemTokenInfo'
import { verifyIfPhoneNumberAvailable } from './verifyIfPhoneNumberAvailable'
import { generateVerificationToken } from './generateVerificationToken'
export const whatsAppRouter = router({
getPhoneNumber,
getSystemTokenInfo,
verifyIfPhoneNumberAvailable,
generateVerificationToken,
})

View File

@ -0,0 +1,29 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import prisma from '@/lib/prisma'
export const verifyIfPhoneNumberAvailable = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/phoneNumber/{phoneNumberDisplayName}/available',
protect: true,
},
})
.input(z.object({ phoneNumberDisplayName: z.string() }))
.output(
z.object({
message: z.enum(['available', 'taken']),
})
)
.query(async ({ input: { phoneNumberDisplayName } }) => {
const existingWhatsAppCredentials = await prisma.credentials.findFirst({
where: {
type: 'whatsApp',
name: phoneNumberDisplayName,
},
})
if (existingWhatsAppCredentials) return { message: 'taken' }
return { message: 'available' }
})

View File

@ -1,3 +1,4 @@
import { env } from '@typebot.io/env'
import { MemberInWorkspace, User } from '@typebot.io/prisma'
export const isReadWorkspaceFobidden = (
@ -7,7 +8,7 @@ export const isReadWorkspaceFobidden = (
user: Pick<User, 'email' | 'id'>
) => {
if (
process.env.ADMIN_EMAIL === user.email ||
env.ADMIN_EMAIL === user.email ||
workspace.members.find((member) => member.userId === user.id)
)
return false

View File

@ -1,31 +0,0 @@
import { env } from '@typebot.io/env'
import { Client } from 'minio'
export const deleteFilesFromBucket = async ({
urls,
}: {
urls: string[]
}): Promise<void> => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const minioClient = new Client({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
})
const bucket = env.S3_BUCKET ?? 'typebot'
return minioClient.removeObjects(
bucket,
urls
.filter((url) => url.includes(env.S3_ENDPOINT as string))
.map((url) => url.split(`/${bucket}/`)[1])
)
}

View File

@ -1,4 +1,8 @@
export const isCloudProdInstance =
(typeof window !== 'undefined' &&
window.location.hostname === 'app.typebot.io') ||
process.env.NEXTAUTH_URL === 'https://app.typebot.io'
import { env } from '@typebot.io/env'
export const isCloudProdInstance = () => {
if (typeof window !== 'undefined') {
return window.location.hostname === 'app.typebot.io'
}
return env.NEXTAUTH_URL === 'https://app.typebot.io'
}

View File

@ -3,6 +3,7 @@ import { webhookRouter } from '@/features/blocks/integrations/webhook/api/router
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api/getLinkedTypebots'
import { credentialsRouter } from '@/features/credentials/api/router'
import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure'
import { sendWhatsAppInitialMessage } from '@/features/preview/api/sendWhatsAppInitialMessage'
import { resultsRouter } from '@/features/results/api/router'
import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent'
import { themeRouter } from '@/features/theme/api/router'
@ -12,12 +13,14 @@ import { router } from '../../trpc'
import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router'
import { whatsAppRouter } from '@/features/whatsapp/router'
export const trpcRouter = router({
getAppVersionProcedure,
processTelemetryEvent,
getLinkedTypebots,
analytics: analyticsRouter,
sendWhatsAppInitialMessage,
workspace: workspaceRouter,
typebot: typebotRouter,
webhook: webhookRouter,
@ -27,6 +30,7 @@ export const trpcRouter = router({
theme: themeRouter,
collaborators: collaboratorsRouter,
customDomains: customDomainsRouter,
whatsApp: whatsAppRouter,
})
export type AppRouter = typeof trpcRouter

View File

@ -20,6 +20,9 @@ import { TypebotProvider } from '@/features/editor/providers/TypebotProvider'
import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { initPostHogIfEnabled } from '@/features/telemetry/posthog'
initPostHogIfEnabled()
const { ToastContainer, toast } = createStandaloneToast(customTheme)
const App = ({ Component, pageProps }: AppProps) => {
@ -59,7 +62,7 @@ const App = ({ Component, pageProps }: AppProps) => {
<TypebotProvider typebotId={typebotId}>
<WorkspaceProvider typebotId={typebotId}>
<Component {...pageProps} />
{!pathname.endsWith('edit') && isCloudProdInstance && (
{!pathname.endsWith('edit') && isCloudProdInstance() && (
<SupportBubble />
)}
<NewVersionPopup />