@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user