@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -120,6 +120,7 @@ export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
|
||||
currentCredentialsId={options.credentialsId}
|
||||
onCredentialsSelect={updateCredentials}
|
||||
onCreateNewClick={onOpen}
|
||||
credentialsName="Stripe account"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -115,6 +115,7 @@ export const GoogleSheetsSettings = ({
|
||||
currentCredentialsId={options?.credentialsId}
|
||||
onCredentialsSelect={handleCredentialsIdChange}
|
||||
onCreateNewClick={handleCreateNewClick}
|
||||
credentialsName="Sheets account"
|
||||
/>
|
||||
)}
|
||||
<GoogleSheetConnectModal
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -53,6 +53,7 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
|
||||
currentCredentialsId={options?.credentialsId}
|
||||
onCredentialsSelect={updateCredentialsId}
|
||||
onCreateNewClick={onOpen}
|
||||
credentialsName="OpenAI account"
|
||||
/>
|
||||
)}
|
||||
<OpenAICredentialsModal
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -121,6 +121,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
defaultCredentialLabel={env.NEXT_PUBLIC_SMTP_FROM?.match(
|
||||
/<(.*)>/
|
||||
)?.pop()}
|
||||
credentialsName="SMTP account"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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}` },
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ export const TypebotHeader = () => {
|
||||
})
|
||||
|
||||
const handleHelpClick = () => {
|
||||
isCloudProdInstance
|
||||
isCloudProdInstance()
|
||||
? onOpen()
|
||||
: window.open('https://docs.typebot.io', '_blank')
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type UpdateTypebotPayload = Partial<
|
||||
| 'customDomain'
|
||||
| 'resultsTablePreferences'
|
||||
| 'isClosed'
|
||||
| 'whatsAppPhoneNumberId'
|
||||
>
|
||||
>
|
||||
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const phoneNumberKey = 'whatsapp-phone'
|
||||
|
||||
export const getPhoneNumberFromLocalStorage = () =>
|
||||
localStorage.getItem(phoneNumberKey)
|
||||
|
||||
export const setPhoneNumberInLocalStorage = (phoneNumber: string) => {
|
||||
localStorage.setItem(phoneNumberKey, phoneNumber)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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]+$'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ const parseWordpressShortcode = ({
|
||||
publicId: string
|
||||
}) => {
|
||||
return `[typebot typebot="${publicId}"${
|
||||
isCloudProdInstance ? '' : ` host="${getViewerUrl()}"`
|
||||
isCloudProdInstance() ? '' : ` host="${getViewerUrl()}"`
|
||||
}${width ? ` width="${width}"` : ''}${height ? ` height="${height}"` : ''}]
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -23,4 +23,5 @@ export const convertPublicTypebotToTypebot = (
|
||||
isClosed: existingTypebot.isClosed,
|
||||
resultsTablePreferences: existingTypebot.resultsTablePreferences,
|
||||
selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId,
|
||||
whatsAppPhoneNumberId: existingTypebot.whatsAppPhoneNumberId,
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
34
apps/builder/src/features/telemetry/posthog.tsx
Normal file
34
apps/builder/src/features/telemetry/posthog.tsx
Normal 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 }
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
78
apps/builder/src/features/whatsapp/getPhoneNumber.ts
Normal file
78
apps/builder/src/features/whatsapp/getPhoneNumber.ts
Normal 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']
|
||||
}
|
||||
88
apps/builder/src/features/whatsapp/getSystemTokenInfo.ts
Normal file
88
apps/builder/src/features/whatsapp/getSystemTokenInfo.ts
Normal 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']
|
||||
}
|
||||
12
apps/builder/src/features/whatsapp/router.ts
Normal file
12
apps/builder/src/features/whatsapp/router.ts
Normal 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,
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user