2
0

(chat) Improve chat API compatibility with preview mode

This commit is contained in:
Baptiste Arnaud
2023-01-16 12:13:21 +01:00
parent dbe5c3cdb1
commit 7311988901
55 changed files with 4133 additions and 465 deletions

File diff suppressed because it is too large Load Diff

View File

@ -21,8 +21,18 @@ The Chat API allows you to execute (chat) with a typebot.
### How to find my `typebotId`
If you'd like to execute the typebot in preview mode, you will need to provide the ID of the building typebot available in the editor URL:
<img
src="/img/api/typebotId.png"
width="900"
alt="Get typebot ID"
/>
For published typebot execution, you need to provide the public typebot ID available here:
<img
src="/img/api/publicId.png"
width="900"
alt="Get typebot ID"
/>

BIN
apps/docs/static/img/api/publicId.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,33 +1,22 @@
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import {
getExistingResultFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import { Standard } from '@typebot.io/react'
import { BackgroundType, InitialChatReply, Typebot } from 'models'
import { BackgroundType, Typebot } from 'models'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState } from 'react'
import { ErrorPage } from './ErrorPage'
import { SEO } from './Seo'
export type TypebotPageV2Props = {
url: string
typebot: Pick<
Typebot,
'settings' | 'theme' | 'id' | 'name' | 'isClosed' | 'isArchived'
'settings' | 'theme' | 'name' | 'isClosed' | 'isArchived' | 'publicId'
>
}
let hasInitializedChat = false
export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
const { asPath, push } = useRouter()
const [initialChatReply, setInitialChatReply] = useState<InitialChatReply>()
const [error, setError] = useState<Error | undefined>(undefined)
const background = typebot.theme.general.background
const clearQueryParamsIfNecessary = useCallback(() => {
const clearQueryParamsIfNecessary = () => {
const hasQueryParams = asPath.includes('?')
if (
!hasQueryParams ||
@ -35,44 +24,8 @@ export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
)
return
push(asPath.split('?')[0], undefined, { shallow: true })
}, [asPath, push, typebot.settings.general.isHideQueryParamsEnabled])
useEffect(() => {
console.log(open)
clearQueryParamsIfNecessary()
}, [clearQueryParamsIfNecessary])
useEffect(() => {
if (hasInitializedChat) return
hasInitializedChat = true
const prefilledVariables = extractPrefilledVariables()
const existingResultId = getExistingResultFromSession() ?? undefined
getInitialChatReplyQuery({
typebotId: typebot.id,
resultId:
typebot.settings.general.isNewResultOnRefreshEnabled ?? false
? undefined
: existingResultId,
prefilledVariables,
}).then(({ data, error }) => {
if (error && 'code' in error && error.code === 'FORBIDDEN') {
setError(new Error('This bot is now closed.'))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
setInitialChatReply(data)
setResultInSession(data.resultId)
})
}, [
initialChatReply,
typebot.id,
typebot.settings.general.isNewResultOnRefreshEnabled,
])
if (error) {
return <ErrorPage error={error} />
}
return (
<div
style={{
@ -89,20 +42,12 @@ export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
typebotName={typebot.name}
metadata={typebot.settings.metadata}
/>
{initialChatReply && (
<Standard typebotId={typebot.id} initialChatReply={initialChatReply} />
{typebot.publicId && (
<Standard
typebot={typebot.publicId}
onInit={clearQueryParamsIfNecessary}
/>
)}
</div>
)
}
const extractPrefilledVariables = () => {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
return prefilledVariables
}

View File

@ -3,4 +3,9 @@ import { ChoiceInputBlock } from 'models'
export const validateButtonInput = (
buttonBlock: ChoiceInputBlock,
input: string
) => buttonBlock.items.some((item) => item.content === input)
) =>
input
.split(',')
.every((value) =>
buttonBlock.items.some((item) => item.content === value.trim())
)

View File

@ -2,6 +2,7 @@ import { checkChatsUsage } from '@/features/usage'
import {
parsePrefilledVariables,
deepParseVariable,
parseVariables,
} from '@/features/variables'
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
@ -11,15 +12,17 @@ import {
ChatReply,
chatReplySchema,
ChatSession,
PublicTypebot,
Result,
sendMessageInputSchema,
SessionState,
StartParams,
StartTypebot,
Theme,
Typebot,
Variable,
} from 'models'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
import { omit } from 'utils'
export const sendMessageProcedure = publicProcedure
.meta({
@ -37,12 +40,13 @@ export const sendMessageProcedure = publicProcedure
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input, resultId } =
const { sessionId, typebot, messages, input, resultId, dynamicTheme } =
await startSession(startParams)
return {
sessionId,
typebot: typebot
? {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
}
@ -50,6 +54,7 @@ export const sendMessageProcedure = publicProcedure
messages,
input,
resultId,
dynamicTheme,
}
} else {
const { messages, input, logic, newSessionState, integrations } =
@ -67,89 +72,35 @@ export const sendMessageProcedure = publicProcedure
input,
logic,
integrations,
dynamicTheme: parseDynamicThemeReply(newSessionState),
}
}
})
const startSession = async (startParams?: StartParams) => {
if (!startParams?.typebotId)
if (!startParams?.typebot)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No typebotId provided in startParams',
})
const typebotQuery = startParams.isPreview
? await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
publishedTypebot: {
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
},
},
name: true,
isClosed: true,
isArchived: true,
id: true,
},
message: 'No typebot provided in startParams',
})
const typebot =
typebotQuery && 'publishedTypebot' in typebotQuery
? (typebotQuery.publishedTypebot as Pick<
PublicTypebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables'
>)
: (typebotQuery as Pick<
Typebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables' | 'isArchived'
>)
if (!typebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if ('isClosed' in typebot && typebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const hasReachedLimit = !startParams.isPreview
? await checkChatsUsage(startParams.typebotId)
: false
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Your workspace reached its chat limit',
})
const typebot = await getTypebot(startParams)
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({ ...startParams, startVariables })
const result = await getResult({
...startParams,
typebot: typebot.id,
startVariables,
isNewResultOnRefreshEnabled:
typebot.settings.general.isNewResultOnRefreshEnabled ?? false,
})
const initialState: SessionState = {
typebot: {
id: startParams.typebotId,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
@ -161,8 +112,9 @@ const startSession = async (startParams?: StartParams) => {
result: result
? { id: result.id, variables: result.variables, hasStarted: false }
: undefined,
isPreview: false,
currentTypebotId: startParams.typebotId,
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
currentTypebotId: typebot.id,
dynamicTheme: parseDynamicThemeInState(typebot.theme),
}
const {
@ -170,16 +122,26 @@ const startSession = async (startParams?: StartParams) => {
input,
logic,
newSessionState: newInitialState,
} = await startBotFlow(initialState)
} = await startBotFlow(initialState, startParams.startGroupId)
if (!input)
return {
messages,
logic,
typebot: {
id: typebot.id,
settings: deepParseVariable(newInitialState.typebot.variables)(
typebot.settings
),
theme: deepParseVariable(newInitialState.typebot.variables)(
typebot.theme
),
},
dynamicTheme: parseDynamicThemeReply(newInitialState),
}
const sessionState: ChatSession['state'] = {
...(newInitialState ?? initialState),
...newInitialState,
currentBlock: {
groupId: input.groupId,
blockId: input.id,
@ -196,27 +158,122 @@ const startSession = async (startParams?: StartParams) => {
resultId: result?.id,
sessionId: session.id,
typebot: {
settings: deepParseVariable(typebot.variables)(typebot.settings),
theme: deepParseVariable(typebot.variables)(typebot.theme),
id: typebot.id,
settings: deepParseVariable(newInitialState.typebot.variables)(
typebot.settings
),
theme: deepParseVariable(newInitialState.typebot.variables)(
typebot.theme
),
},
messages,
input,
logic,
dynamicTheme: parseDynamicThemeReply(newInitialState),
} satisfies ChatReply
}
const getTypebot = async ({
typebot,
isPreview,
}: Pick<StartParams, 'typebot' | 'isPreview'>): Promise<StartTypebot> => {
if (typeof typebot !== 'string') return typebot
const typebotQuery = isPreview
? await prisma.typebot.findUnique({
where: { id: typebot },
select: {
id: true,
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.publicTypebot.findFirst({
where: { typebot: { publicId: typebot } },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
typebotId: true,
typebot: {
select: {
isArchived: true,
isClosed: true,
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
},
},
})
const parsedTypebot =
typebotQuery && 'typebot' in typebotQuery
? ({
id: typebotQuery.typebotId,
...omit(typebotQuery.typebot, 'workspace'),
...omit(typebotQuery, 'typebot', 'typebotId'),
} as StartTypebot & Pick<Typebot, 'isArchived' | 'isClosed'>)
: (typebotQuery as StartTypebot & Pick<Typebot, 'isArchived'>)
if (
!parsedTypebot ||
('isArchived' in parsedTypebot && parsedTypebot.isArchived)
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if ('isClosed' in parsedTypebot && parsedTypebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const hasReachedLimit =
typebotQuery && 'typebot' in typebotQuery
? await checkChatsUsage({
typebotId: parsedTypebot.id,
workspace: typebotQuery.typebot.workspace,
})
: false
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You have reached your chats limit',
})
return parsedTypebot
}
const getResult = async ({
typebotId,
typebot,
isPreview,
resultId,
startVariables,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
isNewResultOnRefreshEnabled,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebot'> & {
startVariables: Variable[]
isNewResultOnRefreshEnabled: boolean
}) => {
if (isPreview) return undefined
if (isPreview || typeof typebot !== 'string') return undefined
const data = {
isCompleted: false,
typebotId: typebotId,
typebotId: typebot,
variables: { set: startVariables.filter((variable) => variable.value) },
} satisfies Prisma.ResultUncheckedCreateInput
const select = {
@ -225,7 +282,7 @@ const getResult = async ({
hasStarted: true,
} satisfies Prisma.ResultSelect
return (
resultId
resultId && !isNewResultOnRefreshEnabled
? await prisma.result.update({
where: { id: resultId },
data,
@ -237,3 +294,36 @@ const getResult = async ({
})
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
}
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
return {
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
? guestAvatarUrl
: undefined,
}
}
const parseDynamicThemeReply = (
state: SessionState | undefined
): ChatReply['dynamicTheme'] => {
if (!state?.dynamicTheme) return
return {
hostAvatarUrl: parseVariables(state?.typebot.variables)(
state.dynamicTheme.hostAvatarUrl
),
guestAvatarUrl: parseVariables(state?.typebot.variables)(
state.dynamicTheme.guestAvatarUrl
),
}
}

View File

@ -23,7 +23,7 @@ export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
async (
group: Group
): Promise<ChatReply & { newSessionState?: SessionState }> => {
): Promise<ChatReply & { newSessionState: SessionState }> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? []
let logic: ChatReply['logic'] = currentReply?.logic
let integrations: ChatReply['integrations'] = currentReply?.integrations
@ -72,8 +72,10 @@ export const executeGroup =
integrations = executionResponse.integrations
if (executionResponse.newSessionState)
newSessionState = executionResponse.newSessionState
if (executionResponse.outgoingEdgeId)
if (executionResponse.outgoingEdgeId) {
nextEdgeId = executionResponse.outgoingEdgeId
break
}
}
if (!nextEdgeId) return { messages, newSessionState, logic, integrations }

View File

@ -1,13 +1,26 @@
import { TRPCError } from '@trpc/server'
import { ChatReply, SessionState } from 'models'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
export const startBotFlow = async (
state: SessionState
): Promise<ChatReply & { newSessionState?: SessionState }> => {
state: SessionState,
startGroupId?: string
): Promise<ChatReply & { newSessionState: SessionState }> => {
if (startGroupId) {
const group = state.typebot.groups.find(
(group) => group.id === startGroupId
)
if (!group)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "startGroupId doesn't exist",
})
return executeGroup(state)(group)
}
const firstEdgeId = state.typebot.groups[0].blocks[0].outgoingEdgeId
if (!firstEdgeId) return { messages: [] }
if (!firstEdgeId) return { messages: [], newSessionState: state }
const nextGroup = getNextGroup(state)(firstEdgeId)
if (!nextGroup) return { messages: [] }
if (!nextGroup) return { messages: [], newSessionState: state }
return executeGroup(state)(nextGroup.group)
}

View File

@ -37,11 +37,10 @@ test('API chat execution should work on preview bot', async ({ request }) => {
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
typebot: typebotId,
isPreview: true,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
} satisfies SendMessageInput,
})
).json()
expect(resultId).toBeUndefined()
@ -75,10 +74,9 @@ test('API chat execution should work on published bot', async ({ request }) => {
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
typebot: publicId,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
} satisfies SendMessageInput,
})
).json()
chatSessionId = sessionId

View File

@ -27,8 +27,8 @@ test('Result should be overwritten on page refresh', async ({ page }) => {
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),

View File

@ -4,14 +4,28 @@ import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { Workspace } from 'models'
import { env, getChatsLimit, isDefined } from 'utils'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
export const checkChatsUsage = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
export const checkChatsUsage = async (props: {
typebotId: string
workspace?: Pick<
Workspace,
| 'id'
| 'plan'
| 'additionalChatsIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'customChatsLimit'
>
}) => {
const typebot = props.workspace
? null
: await prisma.typebot.findUnique({
where: {
id: typebotId,
id: props.typebotId,
},
include: {
workspace: {
@ -27,7 +41,7 @@ export const checkChatsUsage = async (typebotId: string) => {
},
})
const workspace = typebot?.workspace
const workspace = props.workspace || typebot?.workspace
if (!workspace) return false

View File

@ -26,7 +26,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const hasReachedLimit = await checkChatsUsage(typebotId)
const hasReachedLimit = await checkChatsUsage({ typebotId })
if (hasReachedLimit) return res.send({ result: null, hasReachedLimit })
const result = await prisma.result.create({
data: {

View File

@ -56,12 +56,12 @@ const getTypebotFromPublicId = async (
const typebot = (await prisma.typebot.findUnique({
where: { publicId },
select: {
id: true,
theme: true,
name: true,
settings: true,
isArchived: true,
isClosed: true,
publicId: true,
},
})) as TypebotPageV2Props['typebot'] | null
if (isNotDefined(typebot)) return null

View File

@ -1,28 +0,0 @@
import { InitialChatReply, SendMessageInput } from 'models'
import { sendRequest } from 'utils'
type Props = {
typebotId: string
resultId?: string
prefilledVariables?: Record<string, string>
}
export async function getInitialChatReplyQuery({
typebotId,
resultId,
prefilledVariables,
}: Props) {
if (!typebotId)
throw new Error('Typebot ID is required to get initial messages')
return sendRequest<InitialChatReply>({
method: 'POST',
url: `/api/v1/sendMessage`,
body: {
startParams: {
typebotId,
resultId,
prefilledVariables,
},
} satisfies SendMessageInput,
})
}

View File

@ -20,7 +20,6 @@ export const parseReadableDate = ({
const fromReadable = new Date(
hasTime ? from : from.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
console.log(to, to.replace(/-/g, '/'))
const toReadable = new Date(
hasTime ? to : to.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)

View File

@ -1,7 +1,7 @@
{
"name": "@typebot.io/js",
"version": "0.0.0",
"description": "",
"version": "0.0.1",
"description": "Javascript library to display typebots on your website",
"main": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {

View File

@ -2,8 +2,7 @@
@tailwind components;
@tailwind utilities;
:host,
:root {
:host {
--typebot-container-bg-image: none;
--typebot-container-bg-color: transparent;
--typebot-container-font-family: 'Open Sans';

View File

@ -1,83 +1,128 @@
import { LiteBadge } from './LiteBadge'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import {
getViewerUrl,
injectCustomHeadCode,
isDefined,
isNotEmpty,
} from 'utils'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { getViewerUrl, injectCustomHeadCode, isEmpty, isNotEmpty } from 'utils'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import css from '../assets/index.css'
import { InitialChatReply, StartParams } from 'models'
import { StartParams } from 'models'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext } from '@/types'
import { BotContext, InitialChatReply } from '@/types'
import { ErrorMessage } from './ErrorMessage'
import {
getExistingResultIdFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
export type BotProps = StartParams & {
initialChatReply?: InitialChatReply
apiHost?: string
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onInit?: () => void
onEnd?: () => void
}
export const Bot = (props: BotProps) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>(props.initialChatReply)
>()
const [error, setError] = createSignal<Error | undefined>(
// eslint-disable-next-line solid/reactivity
isEmpty(isEmpty(props.apiHost) ? getViewerUrl() : props.apiHost)
? new Error('process.env.NEXT_PUBLIC_VIEWER_URL is missing in env')
: undefined
)
onMount(() => {
if (!props.typebotId) return
const initialChatReplyValue = initialChatReply()
if (isDefined(initialChatReplyValue)) {
const customHeadCode =
initialChatReplyValue.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
} else {
const initializeBot = async () => {
const urlParams = new URLSearchParams(location.search)
props.onInit?.()
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
getInitialChatReplyQuery({
typebotId: props.typebotId,
const { data, error } = await getInitialChatReplyQuery({
typebot: props.typebot,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: props.resultId,
resultId: isNotEmpty(props.resultId)
? props.resultId
: getExistingResultIdFromSession(),
startGroupId: props.startGroupId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
}).then((initialChatReply) => {
setInitialChatReply(initialChatReply)
})
if (error && 'code' in error && typeof error.code === 'string') {
if (['BAD_REQUEST', 'FORBIDDEN'].includes(error.code))
setError(new Error('This bot is now closed.'))
if (error.code === 'NOT_FOUND') setError(new Error('Typebot not found.'))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
if (data.resultId) setResultInSession(data.resultId)
setInitialChatReply(data)
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
const customHeadCode = data.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
}
onMount(() => {
initializeBot().then()
})
return (
<Show
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
fallback={() => (
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
)}
>
<>
<style>{css}</style>
<Show when={error()} keyed>
{(error) => <ErrorMessage error={error} />}
</Show>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
initialChatReply={initialChatReply}
initialChatReply={{
...initialChatReply,
typebot: {
...initialChatReply.typebot,
settings:
typeof props.typebot === 'string'
? initialChatReply.typebot?.settings
: props.typebot?.settings,
theme:
typeof props.typebot === 'string'
? initialChatReply.typebot?.theme
: props.typebot?.theme,
},
}}
context={{
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
typebotId: props.typebotId as string,
typebotId: initialChatReply.typebot.id,
resultId: initialChatReply.resultId,
}}
onNewInputBlock={props.onNewInputBlock}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
)}
</Show>
</Show>
</>
)
}
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
}
const BotContent = (props: BotContentProps) => {
@ -98,38 +143,39 @@ const BotContent = (props: BotContentProps) => {
onMount(() => {
injectCustomFont()
if (botContainer) {
if (!botContainer) return
resizeObserver.observe(botContainer)
}
})
createEffect(() => {
if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
})
onCleanup(() => {
if (botContainer) {
if (!botContainer) return
resizeObserver.unobserve(botContainer)
}
})
return (
<>
<style>{css}</style>
<div
ref={botContainer}
class="flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
class="relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
initialChatReply={props.initialChatReply}
onNewInputBlock={props.onNewInputBlock}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
</div>
<Show
when={
props.initialChatReply.typebot.settings.general.isBrandingEnabled
}
when={props.initialChatReply.typebot.settings.general.isBrandingEnabled}
>
<LiteBadge botContainer={botContainer} />
</Show>
</div>
</>
)
}

View File

@ -10,7 +10,9 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
settings: Settings
inputIndex: number
context: BotContext
onScrollToBottom: () => void
onSubmit: (input: string) => void
onEnd?: () => void
onSkip: () => void
}
@ -23,6 +25,9 @@ export const ChatChunk = (props: Props) => {
? displayedMessageIndex()
: displayedMessageIndex() + 1
)
props.onScrollToBottom()
if (!props.input && displayedMessageIndex() === props.messages.length)
return props.onEnd?.()
}
return (

View File

@ -1,31 +1,73 @@
import { ChatReply, InitialChatReply } from 'models'
import { ChatReply, Theme } from 'models'
import { createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext } from '@/types'
import { BotContext, InitialChatReply } from '@/types'
import { executeIntegrations } from '@/utils/executeIntegrations'
import { executeLogic } from '@/utils/executeLogic'
const parseDynamicTheme = (
theme: Theme,
dynamicTheme: ChatReply['dynamicTheme']
): Theme => ({
...theme,
chat: {
...theme.chat,
hostAvatar: theme.chat.hostAvatar
? {
...theme.chat.hostAvatar,
url: dynamicTheme?.hostAvatarUrl,
}
: undefined,
guestAvatar: theme.chat.guestAvatar
? {
...theme.chat.guestAvatar,
url: dynamicTheme?.guestAvatarUrl,
}
: undefined,
},
})
type Props = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
}
export const ConversationContainer = (props: Props) => {
let bottomSpacer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
},
])
const [theme, setTheme] = createSignal(
parseDynamicTheme(
props.initialChatReply.typebot.theme,
props.initialChatReply.dynamicTheme
)
)
const sendMessage = async (message: string) => {
const currentBlockId = chatChunks().at(-1)?.input?.id
if (currentBlockId && props.onAnswer)
props.onAnswer({ message, blockId: currentBlockId })
const data = await sendMessageQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message,
})
if (!data) return
if (data.dynamicTheme) applyDynamicTheme(data.dynamicTheme)
if (data.input?.id && props.onNewInputBlock) {
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
}
if (data.integrations) {
executeIntegrations(data.integrations)
}
@ -41,6 +83,17 @@ export const ConversationContainer = (props: Props) => {
])
}
const applyDynamicTheme = (dynamicTheme: ChatReply['dynamicTheme']) => {
setTheme((theme) => parseDynamicTheme(theme, dynamicTheme))
}
const autoScrollToBottom = () => {
if (!bottomSpacer) return
setTimeout(() => {
bottomSpacer?.scrollIntoView({ behavior: 'smooth' })
}, 200)
}
return (
<div class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view">
<For each={chatChunks()}>
@ -49,16 +102,26 @@ export const ConversationContainer = (props: Props) => {
inputIndex={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={props.initialChatReply.typebot.theme}
theme={theme()}
settings={props.initialChatReply.typebot.settings}
onSubmit={sendMessage}
onScrollToBottom={autoScrollToBottom}
onSkip={() => {
// TODO: implement skip
}}
onEnd={props.onEnd}
context={props.context}
/>
)}
</For>
<BottomSpacer ref={bottomSpacer} />
</div>
)
}
type BottomSpacerProps = {
ref: HTMLDivElement | undefined
}
const BottomSpacer = (props: BottomSpacerProps) => {
return <div ref={props.ref} class="w-full h-32" />
}

View File

@ -0,0 +1,10 @@
type Props = {
error: Error
}
export const ErrorMessage = (props: Props) => {
return (
<div class="h-full flex justify-center items-center flex-col">
<p class="text-5xl">{props.error.message}</p>
</div>
)
}

View File

@ -44,7 +44,7 @@ export const InputChatBlock = (props: Props) => {
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
props.onSubmit(value)
props.onSubmit(value ?? label)
}
return (
@ -96,37 +96,40 @@ const Input = (props: {
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock & { prefilledValue?: string }}
block={props.block as TextInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock & { prefilledValue?: string }}
block={props.block as NumberInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock & { prefilledValue?: string }}
block={props.block as EmailInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock & { prefilledValue?: string }}
block={props.block as UrlInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
block={
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
}
block={props.block as PhoneNumberInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -146,7 +149,8 @@ const Input = (props: {
</Match>
<Match when={props.block.type === InputBlockType.RATING}>
<RatingForm
block={props.block as RatingInputBlock & { prefilledValue?: string }}
block={props.block as RatingInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
/>
</Match>

View File

@ -38,7 +38,7 @@ export const LiteBadge = (props: Props) => {
href={'https://www.typebot.io/?utm_source=litebadge'}
target="_blank"
rel="noopener noreferrer"
class="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
class="absolute py-1 px-2 bg-white z-50 rounded shadow-md lite-badge text-gray-900"
style={{ bottom: '20px' }}
id="lite-badge"
>

View File

@ -1,11 +1,14 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { isNotEmpty } from 'utils'
import { DefaultAvatar } from './DefaultAvatar'
export const Avatar = (props: { avatarSrc?: string }) => (
<Show when={props.avatarSrc !== ''}>
<Show when={props.avatarSrc} keyed fallback={() => <DefaultAvatar />}>
{(currentAvatarSrc) => (
<Show
when={isNotEmpty(props.avatarSrc)}
keyed
fallback={() => <DefaultAvatar />}
>
<figure
class={
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
@ -13,12 +16,10 @@ export const Avatar = (props: { avatarSrc?: string }) => (
}
>
<img
src={currentAvatarSrc}
src={props.avatarSrc}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
)}
</Show>
</Show>
)

View File

@ -1,4 +1,3 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
@ -9,13 +8,13 @@ type ShortTextInputProps = {
export const ShortTextInput = (props: ShortTextInputProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<input
ref={local.ref}
ref={props.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>

View File

@ -3,9 +3,6 @@ import type { Component } from 'solid-js'
export const App: Component = () => {
return (
<Bot
typebotId="clbm11cku000t3b6o01ug8awh"
apiHost="http://localhost:3001"
/>
<Bot typebot="clbm11cku000t3b6o01ug8awh" apiHost="http://localhost:3001" />
)
}

View File

@ -12,7 +12,7 @@ type Props = {
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal({ from: '', to: '' })
return (
<div class="flex flex-col w-full lg:w-4/6">
<div class="flex flex-col">
<div class="flex items-center">
<form
class={'flex justify-between rounded-lg typebot-input pr-2 items-end'}

View File

@ -20,7 +20,6 @@ export const parseReadableDate = ({
const fromReadable = new Date(
hasTime ? from : from.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
console.log(to, to.replace(/-/g, '/'))
const toReadable = new Date(
hasTime ? to : to.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)

View File

@ -1,20 +1,19 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { EmailInputBlock } from 'models'
import { createSignal } from 'solid-js'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: EmailInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
block: EmailInputBlock
defaultValue?: string
hasGuestAvatar: boolean
onSubmit: (value: InputSubmitContent) => void
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
@ -30,6 +29,10 @@ export const EmailInput = (props: Props) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={

View File

@ -1,20 +1,19 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { NumberInputBlock } from 'models'
import { createSignal } from 'solid-js'
import { createSignal, onMount } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock & { prefilledValue?: string }
block: NumberInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
@ -30,6 +29,10 @@ export const NumberInput = (props: NumberInputProps) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={

View File

@ -3,21 +3,19 @@ import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { PhoneNumberInputBlock } from 'models'
import { createSignal, For } from 'solid-js'
import { createSignal, For, onMount } from 'solid-js'
import { phoneCountries } from 'utils/phoneCountries'
type PhoneInputProps = {
block: PhoneNumberInputBlock & { prefilledValue?: string }
block: PhoneNumberInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const PhoneInput = (props: PhoneInputProps) => {
const [selectedCountryCode, setSelectedCountryCode] = createSignal('INT')
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string | undefined) => {
@ -47,11 +45,13 @@ export const PhoneInput = (props: PhoneInputProps) => {
setSelectedCountryCode(event.currentTarget.value)
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
class={'flex items-end justify-between rounded-lg pr-2 typebot-input'}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',

View File

@ -5,20 +5,21 @@ import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from 'utils'
type Props = {
block: RatingInputBlock & { prefilledValue?: string }
block: RatingInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ? Number(props.block.prefilledValue) : undefined
props.defaultValue ? Number(props.defaultValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
props.onSubmit({ value: rating.toString() })
const selectedRating = rating()
if (isNotDefined(selectedRating)) return
props.onSubmit({ value: selectedRating.toString() })
}
const handleClick = (rating: number) => {

View File

@ -1,20 +1,19 @@
import { Textarea, ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { TextInputBlock } from 'models'
import { createSignal } from 'solid-js'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: TextInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
block: TextInputBlock
defaultValue?: string
hasGuestAvatar: boolean
onSubmit: (value: InputSubmitContent) => void
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
@ -31,6 +30,10 @@ export const TextInput = (props: Props) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={

View File

@ -1,20 +1,19 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { UrlInputBlock } from 'models'
import { createSignal } from 'solid-js'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: UrlInputBlock & { prefilledValue?: string }
block: UrlInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => {
@ -36,6 +35,10 @@ export const UrlInput = (props: Props) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={

View File

@ -20,7 +20,7 @@ export const Bubble = (props: BubbleProps) => {
'onClose',
'previewMessage',
'onPreviewMessageClick',
'button',
'theme',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
@ -106,13 +106,13 @@ export const Bubble = (props: BubbleProps) => {
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
button={bubbleProps.button}
previewMessageTheme={bubbleProps.theme?.previewMessage}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
<BubbleButton
{...bubbleProps.button}
{...bubbleProps.theme?.button}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
/>
@ -126,7 +126,7 @@ export const Bubble = (props: BubbleProps) => {
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
}}
class={
'absolute bottom-20 sm:right-4 rounded-lg bg-white w-full sm:w-[400px] max-h-[704px] ' +
'absolute bottom-20 sm:right-4 rounded-lg w-full sm:w-[400px] max-h-[704px] ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
>

View File

@ -1,7 +1,7 @@
import { Show } from 'solid-js'
import { ButtonParams } from '../types'
import { ButtonTheme } from '../types'
type Props = ButtonParams & {
type Props = ButtonTheme & {
isBotOpened: boolean
toggleBot: () => void
}

View File

@ -1,11 +1,11 @@
import { createSignal } from 'solid-js'
import { BubbleParams, PreviewMessageParams } from '../types'
import { PreviewMessageParams, PreviewMessageTheme } from '../types'
export type PreviewMessageProps = Pick<
PreviewMessageParams,
'avatarUrl' | 'message' | 'style'
> &
Pick<BubbleParams, 'button'> & {
'avatarUrl' | 'message'
> & {
previewMessageTheme?: PreviewMessageTheme
onClick: () => void
onCloseClick: () => void
}
@ -23,9 +23,11 @@ export const PreviewMessage = (props: PreviewMessageProps) => {
onClick={props.onClick}
class="absolute bottom-20 right-4 w-64 rounded-md duration-200 flex items-center gap-4 shadow-md animate-fade-in cursor-pointer hover:shadow-lg p-4"
style={{
'font-family': props.style?.fontFamily ?? defaultFontFamily,
'background-color': props.style?.backgroundColor ?? '#F7F8FF',
color: props.style?.color ?? '#303235',
'font-family':
props.previewMessageTheme?.fontFamily ?? defaultFontFamily,
'background-color':
props.previewMessageTheme?.backgroundColor ?? '#F7F8FF',
color: props.previewMessageTheme?.color ?? '#303235',
}}
onMouseEnter={() => setIsPreviewMessageHovered(true)}
onMouseLeave={() => setIsPreviewMessageHovered(false)}
@ -40,8 +42,9 @@ export const PreviewMessage = (props: PreviewMessageProps) => {
return props.onCloseClick()
}}
style={{
'background-color': props.style?.closeButtonBgColor ?? '#F7F8FF',
color: props.style?.closeButtonColor ?? '#303235',
'background-color':
props.previewMessageTheme?.closeButtonBgColor ?? '#F7F8FF',
color: props.previewMessageTheme?.closeButtonColor ?? '#303235',
}}
>
<svg

View File

@ -1,9 +1,14 @@
export type BubbleParams = {
button: ButtonParams
previewMessage: PreviewMessageParams
theme?: BubbleTheme
previewMessage?: PreviewMessageParams
}
export type ButtonParams = {
export type BubbleTheme = {
button?: ButtonTheme
previewMessage?: PreviewMessageTheme
}
export type ButtonTheme = {
backgroundColor?: string
icon?: {
color?: string
@ -15,13 +20,12 @@ export type PreviewMessageParams = {
avatarUrl?: string
message: string
autoShowDelay?: number
style?: PreviewMessageStyle
}
type PreviewMessageStyle = Partial<{
backgroundColor: string
color: string
fontFamily: string
closeButtonBgColor: string
closeButtonColor: string
}>
export type PreviewMessageTheme = {
backgroundColor?: string
color?: string
fontFamily?: string
closeButtonBgColor?: string
closeButtonColor?: string
}

View File

@ -1,5 +1,12 @@
import styles from '../../../assets/index.css'
import { createSignal, onMount, Show, splitProps, onCleanup } from 'solid-js'
import {
createSignal,
onMount,
Show,
splitProps,
onCleanup,
createEffect,
} from 'solid-js'
import { Bot, BotProps } from '../../../components/Bot'
import { CommandData } from '@/features/commands'
import { isDefined } from 'utils'
@ -7,6 +14,8 @@ import { PopupParams } from '../types'
export type PopupProps = BotProps &
PopupParams & {
defaultOpen?: boolean
isOpen?: boolean
onOpen?: () => void
onClose?: () => void
}
@ -18,7 +27,9 @@ export const Popup = (props: PopupProps) => {
'onOpen',
'onClose',
'autoShowDelay',
'style',
'theme',
'isOpen',
'defaultOpen',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
@ -26,10 +37,14 @@ export const Popup = (props: PopupProps) => {
botProps.prefilledVariables
)
const [isBotOpened, setIsBotOpened] = createSignal(false)
const [isBotOpened, setIsBotOpened] = createSignal(
// eslint-disable-next-line solid/reactivity
popupProps.isOpen ?? popupProps.defaultOpen ?? false
)
onMount(() => {
window.addEventListener('click', processWindowClick)
document.addEventListener('pointerdown', processWindowClick)
botContainer?.addEventListener('pointerdown', stopPropagation)
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = popupProps.autoShowDelay
if (isDefined(autoShowDelay)) {
@ -39,16 +54,25 @@ export const Popup = (props: PopupProps) => {
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
window.removeEventListener('click', processWindowClick)
createEffect(() => {
const isOpen = popupProps.isOpen
if (isDefined(isOpen)) setIsBotOpened(isOpen)
})
const processWindowClick = (event: MouseEvent) => {
if (!botContainer || botContainer.contains(event.target as Node)) return
onCleanup(() => {
document.removeEventListener('pointerdown', processWindowClick)
botContainer?.removeEventListener('pointerdown', stopPropagation)
window.removeEventListener('message', processIncomingEvent)
})
const processWindowClick = () => {
setIsBotOpened(false)
}
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation()
}
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
@ -63,16 +87,19 @@ export const Popup = (props: PopupProps) => {
}
const openBot = () => {
setIsBotOpened(true)
if (isBotOpened()) popupProps.onOpen?.()
if (isDefined(props.isOpen)) return
setIsBotOpened(true)
}
const closeBot = () => {
setIsBotOpened(false)
if (isBotOpened()) popupProps.onClose?.()
if (isDefined(props.isOpen)) return
setIsBotOpened(false)
}
const toggleBot = () => {
if (isDefined(props.isOpen)) return
isBotOpened() ? closeBot() : openBot()
}
@ -85,15 +112,11 @@ export const Popup = (props: PopupProps) => {
aria-modal="true"
>
<style>{styles}</style>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity animate-fade-in" />
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in" />
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
class="relative h-[80vh] transform overflow-hidden rounded-lg text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
style={{
width: popupProps.style?.width ?? '100%',
'background-color': popupProps.style?.backgroundColor ?? '#fff',
}}
ref={botContainer}
>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />

View File

@ -1,6 +1,6 @@
export type PopupParams = {
autoShowDelay?: number
style?: {
theme?: {
width?: string
backgroundColor?: string
}

View File

@ -1,28 +1,31 @@
import { InitialChatReply, SendMessageInput, StartParams } from 'models'
import { getViewerUrl, sendRequest } from 'utils'
import { InitialChatReply } from '@/types'
import { SendMessageInput, StartParams } from 'models'
import { getViewerUrl, isEmpty, sendRequest } from 'utils'
export async function getInitialChatReplyQuery({
typebotId,
typebot,
isPreview,
apiHost,
prefilledVariables,
startGroupId,
resultId,
}: StartParams & {
apiHost?: string
}) {
if (!typebotId)
if (!typebot)
throw new Error('Typebot ID is required to get initial messages')
const response = await sendRequest<InitialChatReply>({
return sendRequest<InitialChatReply>({
method: 'POST',
url: `${apiHost ?? getViewerUrl()}/api/v1/sendMessage`,
url: `${isEmpty(apiHost) ? getViewerUrl() : apiHost}/api/v1/sendMessage`,
body: {
startParams: {
isPreview,
typebotId,
typebot,
prefilledVariables,
startGroupId,
resultId,
},
} satisfies SendMessageInput,
})
return response.data
}

View File

@ -1,5 +1,5 @@
import { ChatReply, SendMessageInput } from 'models'
import { getViewerUrl, sendRequest } from 'utils'
import { getViewerUrl, isEmpty, sendRequest } from 'utils'
export async function sendMessageQuery({
apiHost,
@ -7,7 +7,7 @@ export async function sendMessageQuery({
}: SendMessageInput & { apiHost?: string }) {
const response = await sendRequest<ChatReply>({
method: 'POST',
url: `${apiHost ?? getViewerUrl()}/api/v1/sendMessage`,
url: `${isEmpty(apiHost) ? getViewerUrl() : apiHost}/api/v1/sendMessage`,
body,
})

View File

@ -1,3 +1,5 @@
import { ChatReply } from 'models'
export type InputSubmitContent = {
label?: string
value: string
@ -5,7 +7,12 @@ export type InputSubmitContent = {
export type BotContext = {
typebotId: string
resultId: string
resultId?: string
isPreview: boolean
apiHost?: string
}
export type InitialChatReply = ChatReply & {
typebot: NonNullable<ChatReply['typebot']>
sessionId: NonNullable<ChatReply['sessionId']>
}

View File

@ -0,0 +1,17 @@
const sessionStorageKey = 'resultId'
export const getExistingResultIdFromSession = () => {
try {
return sessionStorage.getItem(sessionStorageKey) ?? undefined
} catch {
/* empty */
}
}
export const setResultInSession = (resultId: string) => {
try {
return sessionStorage.setItem(sessionStorageKey, resultId)
} catch {
/* empty */
}
}

View File

@ -0,0 +1,144 @@
import {
Background,
BackgroundType,
ChatTheme,
ContainerColors,
GeneralTheme,
InputColors,
Theme,
} from 'models'
const cssVariableNames = {
general: {
bgImage: '--typebot-container-bg-image',
bgColor: '--typebot-container-bg-color',
fontFamily: '--typebot-container-font-family',
},
chat: {
hostBubbles: {
bgColor: '--typebot-host-bubble-bg-color',
color: '--typebot-host-bubble-color',
},
guestBubbles: {
bgColor: '--typebot-guest-bubble-bg-color',
color: '--typebot-guest-bubble-color',
},
inputs: {
bgColor: '--typebot-input-bg-color',
color: '--typebot-input-color',
placeholderColor: '--typebot-input-placeholder-color',
},
buttons: {
bgColor: '--typebot-button-bg-color',
color: '--typebot-button-color',
},
},
}
export const setCssVariablesValue = (
theme: Theme | undefined,
container: HTMLDivElement
) => {
if (!theme) return
const documentStyle = container?.style
if (!documentStyle) return
if (theme.general) setGeneralTheme(theme.general, documentStyle)
if (theme.chat) setChatTheme(theme.chat, documentStyle)
}
const setGeneralTheme = (
generalTheme: GeneralTheme,
documentStyle: CSSStyleDeclaration
) => {
const { background, font } = generalTheme
if (background) setTypebotBackground(background, documentStyle)
if (font) documentStyle.setProperty(cssVariableNames.general.fontFamily, font)
}
const setChatTheme = (
chatTheme: ChatTheme,
documentStyle: CSSStyleDeclaration
) => {
const { hostBubbles, guestBubbles, buttons, inputs } = chatTheme
if (hostBubbles) setHostBubbles(hostBubbles, documentStyle)
if (guestBubbles) setGuestBubbles(guestBubbles, documentStyle)
if (buttons) setButtons(buttons, documentStyle)
if (inputs) setInputs(inputs, documentStyle)
}
const setHostBubbles = (
hostBubbles: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (hostBubbles.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.hostBubbles.bgColor,
hostBubbles.backgroundColor
)
if (hostBubbles.color)
documentStyle.setProperty(
cssVariableNames.chat.hostBubbles.color,
hostBubbles.color
)
}
const setGuestBubbles = (
guestBubbles: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (guestBubbles.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.guestBubbles.bgColor,
guestBubbles.backgroundColor
)
if (guestBubbles.color)
documentStyle.setProperty(
cssVariableNames.chat.guestBubbles.color,
guestBubbles.color
)
}
const setButtons = (
buttons: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (buttons.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.buttons.bgColor,
buttons.backgroundColor
)
if (buttons.color)
documentStyle.setProperty(
cssVariableNames.chat.buttons.color,
buttons.color
)
}
const setInputs = (inputs: InputColors, documentStyle: CSSStyleDeclaration) => {
if (inputs.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.inputs.bgColor,
inputs.backgroundColor
)
if (inputs.color)
documentStyle.setProperty(cssVariableNames.chat.inputs.color, inputs.color)
if (inputs.placeholderColor)
documentStyle.setProperty(
cssVariableNames.chat.inputs.placeholderColor,
inputs.placeholderColor
)
}
const setTypebotBackground = (
background: Background,
documentStyle: CSSStyleDeclaration
) => {
documentStyle.setProperty(
background?.type === BackgroundType.IMAGE
? cssVariableNames.general.bgImage
: cssVariableNames.general.bgColor,
background.type === BackgroundType.NONE
? 'transparent'
: background.content ?? '#ffffff'
)
}

View File

@ -24,8 +24,14 @@ const typebotInSessionStateSchema = publicTypebotSchema.pick({
variables: true,
})
const dynamicThemeSchema = z.object({
hostAvatarUrl: z.string().optional(),
guestAvatarUrl: z.string().optional(),
})
export const sessionStateSchema = z.object({
typebot: typebotInSessionStateSchema,
dynamicTheme: dynamicThemeSchema.optional(),
linkedTypebots: z.object({
typebots: z.array(typebotInSessionStateSchema),
queue: z.array(z.object({ edgeId: z.string(), typebotId: z.string() })),
@ -95,11 +101,21 @@ const codeToExecuteSchema = z.object({
),
})
const startTypebotSchema = typebotSchema.pick({
id: true,
groups: true,
edges: true,
variables: true,
settings: true,
theme: true,
})
const startParamsSchema = z.object({
typebotId: z.string({
description:
'[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)',
}),
typebot: startTypebotSchema
.or(z.string())
.describe(
'Either a Typebot ID or a Typebot object. If you provide a Typebot object, it will be executed in preview mode. ([How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)).'
),
isPreview: z
.boolean()
.optional()
@ -110,7 +126,16 @@ const startParamsSchema = z.object({
.string()
.optional()
.describe("Provide it if you'd like to overwrite an existing result."),
prefilledVariables: z.record(z.unknown()).optional(),
startGroupId: z
.string()
.optional()
.describe('Start chat from a specific group.'),
prefilledVariables: z
.record(z.unknown())
.optional()
.describe(
'[More info about prefilled variables.](https://docs.typebot.io/editor/variables#prefilled-variables)'
),
})
export const sendMessageInputSchema = z.object({
@ -158,25 +183,20 @@ export const chatReplySchema = z.object({
})
.optional(),
sessionId: z.string().optional(),
typebot: typebotSchema.pick({ theme: true, settings: true }).optional(),
typebot: typebotSchema
.pick({ id: true, theme: true, settings: true })
.optional(),
resultId: z.string().optional(),
dynamicTheme: dynamicThemeSchema.optional(),
})
export const initialChatReplySchema = z
.object({
sessionId: z.string(),
resultId: z.string(),
typebot: typebotSchema.pick({ theme: true, settings: true }),
})
.and(chatReplySchema)
export type ChatSession = z.infer<typeof chatSessionSchema>
export type SessionState = z.infer<typeof sessionStateSchema>
export type TypebotInSession = z.infer<typeof typebotInSessionStateSchema>
export type ChatReply = z.infer<typeof chatReplySchema>
export type InitialChatReply = z.infer<typeof initialChatReplySchema>
export type ChatMessage = z.infer<typeof chatMessageSchema>
export type SendMessageInput = z.infer<typeof sendMessageInputSchema>
export type CodeToExecute = z.infer<typeof codeToExecuteSchema>
export type StartParams = z.infer<typeof startParamsSchema>
export type RuntimeOptions = z.infer<typeof runtimeOptionsSchema>
export type StartTypebot = z.infer<typeof startTypebotSchema>

View File

@ -1,7 +1,7 @@
{
"name": "@typebot.io/react",
"version": "1.0.0",
"description": "",
"version": "0.0.1",
"description": "React library to display typebots on your website",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",

View File

@ -1,5 +1,8 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import type { BubbleProps } from '@typebot.io/js'
import { defaultBubbleProps } from './constants'
type Props = BubbleProps & { style?: React.CSSProperties; className?: string }
declare global {
namespace JSX {
@ -7,18 +10,54 @@ declare global {
'typebot-bubble': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
> & { class?: string }
}
}
}
export const Bubble = (props: BubbleProps) => {
export const Bubble = ({ style, className, ...props }: Props) => {
const ref = useRef<(HTMLDivElement & Props) | null>(null)
useEffect(() => {
;(async () => {
const { registerBubbleComponent } = await import('@typebot.io/js')
registerBubbleComponent(props)
registerBubbleComponent(defaultBubbleProps)
})()
}, [props])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <typebot-bubble />
useEffect(() => {
if (!ref.current) return
ref.current.typebot = props.typebot
ref.current.prefilledVariables = props.prefilledVariables
ref.current.onClose = props.onClose
ref.current.onOpen = props.onOpen
ref.current.onNewInputBlock = props.onNewInputBlock
ref.current.onAnswer = props.onAnswer
ref.current.onPreviewMessageClick = props.onPreviewMessageClick
ref.current.onEnd = props.onEnd
ref.current.onInit = props.onInit
}, [
props.onAnswer,
props.onClose,
props.onNewInputBlock,
props.onOpen,
props.onPreviewMessageClick,
props.prefilledVariables,
props.typebot,
props.onEnd,
props.onInit,
])
return (
<typebot-bubble
ref={ref}
api-host={props.apiHost}
start-group-id={props.startGroupId}
result-id={props.resultId}
is-preview={props.isPreview}
class={className}
style={style}
/>
)
}

View File

@ -1,5 +1,8 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import type { PopupProps } from '@typebot.io/js'
import { defaultPopupProps } from './constants'
type Props = PopupProps & { style?: React.CSSProperties; className?: string }
declare global {
namespace JSX {
@ -7,18 +10,54 @@ declare global {
'typebot-popup': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
> & { class?: string }
}
}
}
export const Popup = (props: PopupProps) => {
export const Popup = ({ style, className, ...props }: Props) => {
const ref = useRef<(HTMLDivElement & Props) | null>(null)
useEffect(() => {
;(async () => {
const { registerPopupComponent } = await import('@typebot.io/js')
registerPopupComponent(props)
registerPopupComponent(defaultPopupProps)
})()
}, [props])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <typebot-popup />
useEffect(() => {
if (!ref.current) return
ref.current.typebot = props.typebot
ref.current.prefilledVariables = props.prefilledVariables
ref.current.onClose = props.onClose
ref.current.onOpen = props.onOpen
ref.current.onNewInputBlock = props.onNewInputBlock
ref.current.onAnswer = props.onAnswer
ref.current.onEnd = props.onEnd
ref.current.onInit = props.onInit
}, [
props.onAnswer,
props.onClose,
props.onEnd,
props.onNewInputBlock,
props.onOpen,
props.onInit,
props.prefilledVariables,
props.typebot,
])
return (
<typebot-popup
ref={ref}
api-host={props.apiHost}
start-group-id={props.startGroupId}
result-id={props.resultId}
is-preview={props.isPreview}
is-open={props.isOpen}
default-open={props.defaultOpen}
class={className}
style={style}
/>
)
}

View File

@ -1,7 +1,8 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import type { BotProps } from '@typebot.io/js'
import { defaultBotProps } from './constants'
type Props = BotProps
type Props = BotProps & { style?: React.CSSProperties; className?: string }
declare global {
namespace JSX {
@ -9,19 +10,48 @@ declare global {
'typebot-standard': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
> & { class?: string }
}
}
}
export const Standard = (props: Props) => {
export const Standard = ({ style, className, ...props }: Props) => {
const ref = useRef<(HTMLDivElement & Props) | null>(null)
useEffect(() => {
;(async () => {
const { registerStandardComponent } = await import('@typebot.io/js')
registerStandardComponent(props)
registerStandardComponent(defaultBotProps)
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <typebot-standard />
useEffect(() => {
if (!ref.current) return
ref.current.typebot = props.typebot
ref.current.prefilledVariables = props.prefilledVariables
ref.current.onNewInputBlock = props.onNewInputBlock
ref.current.onAnswer = props.onAnswer
ref.current.onEnd = props.onEnd
ref.current.onInit = props.onInit
}, [
props.onAnswer,
props.onNewInputBlock,
props.prefilledVariables,
props.typebot,
props.onEnd,
props.onInit,
])
return (
<typebot-standard
ref={ref}
api-host={props.apiHost}
start-group-id={props.startGroupId}
style={style}
class={className}
result-id={props.resultId}
is-preview={props.isPreview}
/>
)
}

View File

@ -0,0 +1,32 @@
import type { BotProps, PopupProps, BubbleProps } from '@typebot.io/js'
export const defaultBotProps: BotProps = {
typebot: '',
onNewInputBlock: undefined,
onAnswer: undefined,
onEnd: undefined,
onInit: undefined,
isPreview: undefined,
startGroupId: undefined,
prefilledVariables: undefined,
apiHost: undefined,
resultId: undefined,
}
export const defaultPopupProps: PopupProps = {
...defaultBotProps,
onClose: undefined,
onOpen: undefined,
theme: undefined,
autoShowDelay: undefined,
isOpen: undefined,
defaultOpen: undefined,
}
export const defaultBubbleProps: BubbleProps = {
...defaultBotProps,
onClose: undefined,
onOpen: undefined,
theme: undefined,
previewMessage: undefined,
}

View File

@ -5,6 +5,6 @@ import { Popup } from './Popup'
export { Standard, Bubble, Popup }
export default { Standard, Bubble, Popup }
// export default { Standard, Bubble, Popup }
export * from '@typebot.io/js/src/features/commands'

View File

@ -32,7 +32,7 @@ export const Default = () => {
</div>
<Bubble
typebotId="ladleTypebot"
typebot="ladleTypebot"
apiHost="http://localhost:3001"
prefilledVariables={{
Name: 'John',
@ -42,12 +42,15 @@ export const Default = () => {
message: 'Hello, I am a preview message',
autoShowDelay: 3000,
}}
button={{
theme={{
button: {
backgroundColor: '#FF7537',
icon: {
color: 'white',
},
},
}}
isPreview
/>
</div>
)

View File

@ -7,9 +7,10 @@ export const Default = () => {
<button onClick={open}>Open modal</button>
<button onClick={toggle}>Toggle modal</button>
<Popup
typebotId="ladleTypebot"
typebot="clctayswj000l3b6y2vkh8kwg"
apiHost="http://localhost:3001"
autoShowDelay={3000}
isPreview
/>
</>
)

View File

@ -3,7 +3,11 @@ import { Standard } from '..'
export const Default = () => {
return (
<div style={{ height: '500px' }}>
<Standard typebotId="ladleTypebot" apiHost="http://localhost:3001" />
<Standard
typebot="ladleTypebot"
apiHost="http://localhost:3001"
isPreview
/>
</div>
)
}