2
0

♻️ Re-organize workspace folders

This commit is contained in:
Baptiste Arnaud
2023-03-15 08:35:16 +01:00
parent 25c367901f
commit cbc8194f19
987 changed files with 2716 additions and 2770 deletions

View File

@ -0,0 +1 @@
/src/react/**

View File

@ -0,0 +1,10 @@
module.exports = {
root: true,
extends: ['custom', 'plugin:solid/typescript'],
plugins: ['solid'],
rules: {
'@next/next/no-img-element': 'off',
'@next/next/no-html-link-for-pages': 'off',
'solid/no-innerhtml': 'off',
},
}

2
packages/embeds/js/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,6 @@
src
.eslintignore
.eslintrc.cjs
rollup.config.js
tailwind.config.cjs
tsconfig.json

View File

@ -0,0 +1 @@
//registry.npmjs.org/:_authToken=${NPM_TOKEN}

View File

@ -0,0 +1,169 @@
# Typebot JS library
Frontend library to embed typebots from [Typebot](https://www.typebot.io/).
## Installation
### Using npm
To install, simply run:
```bash
npm install @typebot.io/js
```
### Directly in your HTML
```
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js/dist/web.js'
Typebot.initStandard({
typebot: 'my-typebot',
})
</script>
<typebot-standard style="width: 100%; height: 600px; "></typebot-standard>
```
## Standard
You can get the standard HTML and Javascript code by clicking on the "HTML & Javascript" button in the "Share" tab of your typebot.
There, you can change the container dimensions. Here is a code example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.0.9/dist/web.js'
Typebot.initStandard({
typebot: 'my-typebot',
})
</script>
<typebot-standard style="width: 100%; height: 600px; "></typebot-standard>
```
This code is creating a container with a 100% width (will match parent width) and 600px height.
## Popup
You can get the popup HTML and Javascript code by clicking on the "HTML & Javascript" button in the "Share" tab of your typebot.
Here is an example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.0.9/dist/web.js'
Typebot.initPopup({
typebot: 'my-typebot',
apiHost: 'http://localhost:3001',
autoShowDelay: 3000,
})
</script>
```
This code will automatically trigger the popup window after 3 seconds.
### Open or Close a popup
You can use these commands:
```js
Typebot.open()
```
```js
Typebot.close()
```
```js
Typebot.toggle()
```
You can bind these commands on a button element, for example:
```html
<button onclick="Typebot.open()">Contact us</button>
```
## Bubble
You can get the bubble HTML and Javascript code by clicking on the "HTML & Javascript" button in the "Share" tab of your typebot.
Here is an example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.0.9/dist/web.js'
Typebot.initBubble({
typebot: 'my-typebot',
previewMessage: {
message: 'I have a question for you!',
autoShowDelay: 5000,
avatarUrl: 'https://avatars.githubusercontent.com/u/16015833?v=4',
},
theme: {
button: { backgroundColor: '#0042DA', iconColor: '#FFFFFF' },
previewMessage: { backgroundColor: '#ffffff', textColor: 'black' },
chatWindow: { backgroundColor: '#ffffff' },
},
})
</script>
```
This code will show the bubble and let a preview message appear after 5 seconds.
### Open or close the preview message
You can use these commands:
```js
Typebot.showPreviewMessage()
```
```js
Typebot.hidePreviewMessage()
```
### Open or close the typebot
You can use these commands:
```js
Typebot.open()
```
```js
Typebot.close()
```
```js
Typebot.toggle()
```
You can bind these commands on a button element, for example:
```html
<button onclick="Typebot.open()">Contact us</button>
```
## Additional configuration
You can prefill the bot variable values in your embed code by adding the `prefilledVariables` option. Here is an example:
```js
Typebot.initStandard({
typebot: 'my-typebot',
prefilledVariables: {
'Current URL': 'https://my-site/account',
'User name': 'John Doe',
},
})
```
It will prefill the `Current URL` variable with "https://my-site/account" and the `User name` variable with "John Doe". More info about variables: [here](/editor/variables).
Note that if your site URL contains query params (i.e. https://typebot.io?User%20name=John%20Doe), the variables will automatically be injected to the typebot. So you don't need to manually transfer query params to the bot embed configuration.

View File

@ -0,0 +1,41 @@
{
"name": "@typebot.io/js",
"version": "0.0.28",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "rollup --watch --config rollup.config.js",
"build": "rollup --config rollup.config.js",
"lint": "eslint --fix \"src/**/*.ts*\""
},
"license": "MIT",
"dependencies": {
"@stripe/stripe-js": "1.49.0",
"solid-element": "1.6.4",
"solid-js": "1.6.14"
},
"devDependencies": {
"@babel/preset-typescript": "7.21.0",
"@rollup/plugin-babel": "6.0.3",
"@rollup/plugin-node-resolve": "15.0.1",
"@rollup/plugin-terser": "0.4.0",
"@rollup/plugin-typescript": "11.0.0",
"autoprefixer": "10.4.14",
"babel-preset-solid": "1.6.13",
"eslint": "8.36.0",
"eslint-config-custom": "workspace:*",
"eslint-plugin-solid": "0.11.0",
"@typebot.io/schemas": "workspace:*",
"postcss": "8.4.21",
"react": "18.2.0",
"rollup": "3.19.1",
"rollup-plugin-postcss": "4.0.2",
"rollup-plugin-typescript-paths": "1.4.0",
"tailwindcss": "3.2.7",
"@typebot.io/tsconfig": "workspace:*",
"typescript": "4.9.5",
"@typebot.io/lib": "workspace:*"
}
}

View File

@ -0,0 +1,52 @@
import resolve from '@rollup/plugin-node-resolve'
import terser from '@rollup/plugin-terser'
import { babel } from '@rollup/plugin-babel'
import postcss from 'rollup-plugin-postcss'
import autoprefixer from 'autoprefixer'
import tailwindcss from 'tailwindcss'
import typescript from '@rollup/plugin-typescript'
import { typescriptPaths } from 'rollup-plugin-typescript-paths'
const extensions = ['.ts', '.tsx']
const indexConfig = {
input: './src/index.ts',
output: {
file: 'dist/index.js',
format: 'es',
},
plugins: [
resolve({ extensions }),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
presets: ['solid', '@babel/preset-typescript'],
extensions,
}),
postcss({
plugins: [autoprefixer(), tailwindcss()],
extract: false,
modules: false,
autoModules: false,
minimize: true,
inject: false,
}),
typescript(),
typescriptPaths({ preserveExtensions: true }),
terser({ output: { comments: false } }),
],
}
const configs = [
indexConfig,
{
...indexConfig,
input: './src/web.ts',
output: {
file: 'dist/web.js',
format: 'es',
},
},
]
export default configs

View File

@ -0,0 +1,26 @@
.lite-badge {
position: absolute !important;
padding: 4px 8px !important;
background-color: white !important;
z-index: 50 !important;
border-radius: 4px !important;
color: rgb(17 24 39) !important;
gap: 8px !important;
font-size: 14px !important;
line-height: 20px !important;
font-weight: 600 !important;
border-width: 1px !important;
/* Make sure the badge is always displayed */
opacity: 1 !important;
visibility: visible !important;
display: flex !important;
top: auto !important;
right: auto !important;
left: auto !important;
bottom: 20px !important;
transition: background-color 0.2s ease-in-out !important;
}
.lite-badge:hover {
background-color: #f7f8ff !important;
}

View File

@ -0,0 +1,235 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:host {
--typebot-container-bg-image: none;
--typebot-container-bg-color: transparent;
--typebot-container-font-family: 'Open Sans';
--typebot-button-bg-color: #0042da;
--typebot-button-color: #ffffff;
--typebot-host-bubble-bg-color: #f7f8ff;
--typebot-host-bubble-color: #303235;
--typebot-guest-bubble-bg-color: #ff8e21;
--typebot-guest-bubble-color: #ffffff;
--typebot-input-bg-color: #ffffff;
--typebot-input-color: #303235;
--typebot-input-placeholder-color: #9095a0;
--typebot-header-bg-color: #ffffff;
--typebot-header-color: #303235;
/* Phone input */
--PhoneInputCountryFlag-borderColor: transparent;
--PhoneInput-color--focus: transparent;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollable-container::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollable-container {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.text-fade-in {
transition: opacity 400ms ease-in 200ms;
}
.bubble-typing {
transition: width 400ms ease-out, height 400ms ease-out;
}
.bubble1,
.bubble2,
.bubble3 {
background-color: var(--typebot-host-bubble-color);
opacity: 0.5;
}
.bubble1 {
animation: chatBubbles 1s ease-in-out infinite;
}
.bubble2 {
animation: chatBubbles 1s ease-in-out infinite;
animation-delay: 0.3s;
}
.bubble3 {
animation: chatBubbles 1s ease-in-out infinite;
animation-delay: 0.5s;
}
@keyframes chatBubbles {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
100% {
transform: translateY(0);
}
}
button,
input,
textarea {
font-weight: 300;
}
.slate-a {
text-decoration: underline;
}
.slate-html-container > div {
min-height: 24px;
}
.slate-bold {
font-weight: bold;
}
.slate-italic {
font-style: oblique;
}
.slate-underline {
text-decoration: underline;
}
.text-input::-webkit-input-placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.text-input::-moz-placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.text-input::placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.typebot-container {
background-image: var(--typebot-container-bg-image);
background-color: var(--typebot-container-bg-color);
font-family: var(--typebot-container-font-family), -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
.custom-header {
color: var(--typebot-header-color);
background-color: var(--typebot-header-bg-color);
}
.typebot-button {
color: var(--typebot-button-color);
background-color: var(--typebot-button-bg-color);
border: 1px solid var(--typebot-button-bg-color);
}
.typebot-button.selectable {
color: var(--typebot-host-bubble-color);
background-color: var(--typebot-host-bubble-bg-color);
border: 1px solid var(--typebot-button-bg-color);
}
.typebot-host-bubble {
color: var(--typebot-host-bubble-color);
}
.typebot-host-bubble > .bubble-typing {
background-color: var(--typebot-host-bubble-bg-color);
border: var(--typebot-host-bubble-border);
}
.typebot-guest-bubble {
color: var(--typebot-guest-bubble-color);
background-color: var(--typebot-guest-bubble-bg-color);
}
.typebot-input {
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
box-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1);
}
.typebot-input-error-message {
color: var(--typebot-input-color);
}
.typebot-button > .send-icon {
fill: var(--typebot-button-color);
}
.typebot-chat-view {
max-width: 800px;
}
.ping span {
background-color: var(--typebot-button-bg-color);
}
.rating-icon-container svg {
width: 42px;
height: 42px;
stroke: var(--typebot-button-bg-color);
fill: var(--typebot-host-bubble-bg-color);
transition: fill 100ms ease-out;
}
.rating-icon-container.selected svg {
fill: var(--typebot-button-bg-color);
}
.rating-icon-container:hover svg {
filter: brightness(0.9);
}
.rating-icon-container:active svg {
filter: brightness(0.75);
}
.upload-progress-bar {
background-color: var(--typebot-button-bg-color);
}
.total-files-indicator {
background-color: var(--typebot-button-bg-color);
color: var(--typebot-button-color);
font-size: 10px;
}
.typebot-upload-input {
transition: border-color 100ms ease-out;
}
.typebot-upload-input.dragging-over {
border-color: var(--typebot-button-bg-color);
}
.secondary-button {
background-color: var(--typebot-host-bubble-bg-color);
color: var(--typebot-host-bubble-color);
}
.typebot-country-select {
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
}
.typebot-date-input {
color-scheme: light;
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
}

View File

@ -0,0 +1,211 @@
import { LiteBadge } from './LiteBadge'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { injectCustomHeadCode, isNotEmpty } from '@typebot.io/lib'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
import { ErrorMessage } from './ErrorMessage'
import {
getExistingResultIdFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
import immutableCss from '../assets/immutable.css'
export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typebot: string | any
isPreview?: boolean
resultId?: string
startGroupId?: string
prefilledVariables?: Record<string, unknown>
apiHost?: string
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onInit?: () => void
onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void
}
export const Bot = (props: BotProps & { class?: string }) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>()
const [customCss, setCustomCss] = createSignal('')
const [isInitialized, setIsInitialized] = createSignal(false)
const [error, setError] = createSignal<Error | undefined>()
const initializeBot = async () => {
setIsInitialized(true)
const urlParams = new URLSearchParams(location.search)
props.onInit?.()
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
const typebotIdFromProps =
typeof props.typebot === 'string' ? props.typebot : undefined
const { data, error } = await getInitialChatReplyQuery({
typebot: props.typebot,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: isNotEmpty(props.resultId)
? props.resultId
: getExistingResultIdFromSession(typebotIdFromProps),
startGroupId: props.startGroupId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
})
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("The bot you're looking for doesn't exist."))
return
}
if (!data) return setError(new Error("Error! Couldn't initiate the chat."))
if (data.resultId && typebotIdFromProps)
setResultInSession(typebotIdFromProps, data.resultId)
setInitialChatReply(data)
setCustomCss(data.typebot.theme.customCss ?? '')
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
if (data.logs) props.onNewLogs?.(data.logs)
const customHeadCode = data.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
}
createEffect(() => {
if (!props.typebot || isInitialized()) return
initializeBot().then()
})
createEffect(() => {
if (typeof props.typebot === 'string') return
setCustomCss(props.typebot.theme.customCss ?? '')
})
onCleanup(() => {
setIsInitialized(false)
})
return (
<>
<style>{customCss()}</style>
<style>{immutableCss}</style>
<Show when={error()} keyed>
{(error) => <ErrorMessage error={error} />}
</Show>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
class={props.class}
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:
typeof props.typebot !== 'string' || (props.isPreview ?? false),
typebotId: initialChatReply.typebot.id,
resultId: initialChatReply.resultId,
}}
onNewInputBlock={props.onNewInputBlock}
onNewLogs={props.onNewLogs}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
)}
</Show>
</>
)
}
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
class?: string
onNewInputBlock?: (block: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void
}
const BotContent = (props: BotContentProps) => {
let botContainer: HTMLDivElement | undefined
const resizeObserver = new ResizeObserver((entries) => {
return setIsMobile(entries[0].target.clientWidth < 400)
})
const injectCustomFont = () => {
const font = document.createElement('link')
font.href = `https://fonts.googleapis.com/css2?family=${
props.initialChatReply.typebot?.theme?.general?.font ?? 'Open Sans'
}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap');')`
font.rel = 'stylesheet'
document.head.appendChild(font)
}
onMount(() => {
injectCustomFont()
if (!botContainer) return
resizeObserver.observe(botContainer)
})
createEffect(() => {
if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
})
onCleanup(() => {
if (!botContainer) return
resizeObserver.unobserve(botContainer)
})
return (
<div
ref={botContainer}
class={
'relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container ' +
props.class
}
>
<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}
onNewLogs={props.onNewLogs}
/>
</div>
<Show
when={props.initialChatReply.typebot.settings.general.isBrandingEnabled}
>
<LiteBadge botContainer={botContainer} />
</Show>
</div>
)
}

View File

@ -0,0 +1,49 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { isMobile } from '@/utils/isMobileSignal'
import { Avatar } from '../avatars/Avatar'
type Props = { hostAvatarSrc?: string }
export const AvatarSideContainer = (props: Props) => {
let avatarContainer: HTMLDivElement | undefined
const [top, setTop] = createSignal<number>(0)
const resizeObserver = new ResizeObserver((entries) =>
setTop(entries[0].target.clientHeight - (isMobile() ? 24 : 40))
)
onMount(() => {
if (avatarContainer) {
resizeObserver.observe(avatarContainer)
}
})
onCleanup(() => {
if (avatarContainer) {
resizeObserver.unobserve(avatarContainer)
}
})
return (
<div
ref={avatarContainer}
class={
'flex mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
(isMobile() ? 'w-6' : 'w-10')
}
>
<div
class={
'absolute mb-2 flex items-center top-0 ' +
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
}
style={{
top: `${top()}px`,
transition: 'top 350ms ease-out',
}}
>
<Avatar initialAvatarSrc={props.hostAvatarSrc} />
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
import { BotContext } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { ChatReply, Settings, Theme } from '@typebot.io/schemas'
import { createSignal, For, onMount, Show } from 'solid-js'
import { HostBubble } from '../bubbles/HostBubble'
import { InputChatBlock } from '../InputChatBlock'
import { AvatarSideContainer } from './AvatarSideContainer'
type Props = Pick<ChatReply, 'messages' | 'input'> & {
theme: Theme
settings: Settings
inputIndex: number
context: BotContext
isLoadingBubbleDisplayed: boolean
onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: () => void
onSubmit: (input: string) => void
onSkip: () => void
onAllBubblesDisplayed: () => void
}
export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
onMount(() => {
if (props.messages.length === 0) {
props.onAllBubblesDisplayed()
}
props.onScrollToBottom()
})
const displayNextMessage = async () => {
const lastBubbleBlockId = props.messages[displayedMessageIndex()].id
await props.onNewBubbleDisplayed(lastBubbleBlockId)
setDisplayedMessageIndex(
displayedMessageIndex() === props.messages.length
? displayedMessageIndex()
: displayedMessageIndex() + 1
)
props.onScrollToBottom()
if (displayedMessageIndex() === props.messages.length) {
props.onAllBubblesDisplayed()
}
}
return (
<div class="flex w-full">
<div class="flex flex-col w-full min-w-0">
<div class="flex">
<Show
when={
props.theme.chat.hostAvatar?.isEnabled &&
props.messages.length > 0
}
>
<AvatarSideContainer
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
/>
</Show>
<div
class="flex-1"
style={{
'margin-right': props.theme.chat.guestAvatar?.isEnabled
? isMobile()
? '32px'
: '48px'
: undefined,
}}
>
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
{(message) => (
<HostBubble
message={message}
typingEmulation={props.settings.typingEmulation}
onTransitionEnd={displayNextMessage}
/>
)}
</For>
</div>
</div>
{props.input && displayedMessageIndex() === props.messages.length && (
<InputChatBlock
block={props.input}
inputIndex={props.inputIndex}
onSubmit={props.onSubmit}
onSkip={props.onSkip}
hasHostAvatar={props.theme.chat.hostAvatar?.isEnabled ?? false}
guestAvatar={props.theme.chat.guestAvatar}
context={props.context}
isInputPrefillEnabled={
props.settings.general.isInputPrefillEnabled ?? true
}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,203 @@
import type { ChatReply, Theme } from '@typebot.io/schemas'
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
import { isNotDefined } from '@typebot.io/lib'
import { executeClientSideAction } from '@/utils/executeClientSideActions'
import { LoadingChunk } from './LoadingChunk'
import { PopupBlockedToast } from './PopupBlockedToast'
const parseDynamicTheme = (
initialTheme: Theme,
dynamicTheme: ChatReply['dynamicTheme']
): Theme => ({
...initialTheme,
chat: {
...initialTheme.chat,
hostAvatar:
initialTheme.chat.hostAvatar && dynamicTheme?.hostAvatarUrl
? {
...initialTheme.chat.hostAvatar,
url: dynamicTheme.hostAvatarUrl,
}
: initialTheme.chat.hostAvatar,
guestAvatar:
initialTheme.chat.guestAvatar && dynamicTheme?.guestAvatarUrl
? {
...initialTheme.chat.guestAvatar,
url: dynamicTheme?.guestAvatarUrl,
}
: initialTheme.chat.guestAvatar,
},
})
type Props = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void
}
export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined
let bottomSpacer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<
Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
clientSideActions: props.initialChatReply.clientSideActions,
},
])
const [dynamicTheme, setDynamicTheme] = createSignal<
ChatReply['dynamicTheme']
>(props.initialChatReply.dynamicTheme)
const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme)
const [isSending, setIsSending] = createSignal(false)
const [blockedPopupUrl, setBlockedPopupUrl] = createSignal<string>()
onMount(() => {
;(async () => {
const initialChunk = chatChunks()[0]
if (initialChunk.clientSideActions) {
const actionsBeforeFirstBubble = initialChunk.clientSideActions.filter(
(action) => isNotDefined(action.lastBubbleBlockId)
)
for (const action of actionsBeforeFirstBubble) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
}
}
})()
})
createEffect(() => {
setTheme(
parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme())
)
})
const sendMessage = async (message: string | undefined) => {
const currentBlockId = [...chatChunks()].pop()?.input?.id
if (currentBlockId && props.onAnswer && message)
props.onAnswer({ message, blockId: currentBlockId })
const longRequest = setTimeout(() => {
setIsSending(true)
}, 1000)
const data = await sendMessageQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message,
})
clearTimeout(longRequest)
setIsSending(false)
if (!data) return
if (data.logs) props.onNewLogs?.(data.logs)
if (data.dynamicTheme) setDynamicTheme(data.dynamicTheme)
if (data.input?.id && props.onNewInputBlock) {
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
}
if (data.clientSideActions) {
const actionsBeforeFirstBubble = data.clientSideActions.filter((action) =>
isNotDefined(action.lastBubbleBlockId)
)
for (const action of actionsBeforeFirstBubble) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
}
}
setChatChunks((displayedChunks) => [
...displayedChunks,
{
input: data.input,
messages: data.messages,
clientSideActions: data.clientSideActions,
},
])
}
const autoScrollToBottom = () => {
if (!bottomSpacer) return
setTimeout(() => {
chatContainer?.scrollTo(0, chatContainer.scrollHeight)
}, 50)
}
const handleAllBubblesDisplayed = async () => {
const lastChunk = [...chatChunks()].pop()
if (!lastChunk) return
if (isNotDefined(lastChunk.input)) {
props.onEnd?.()
}
}
const handleNewBubbleDisplayed = async (blockId: string) => {
const lastChunk = [...chatChunks()].pop()
if (!lastChunk) return
if (lastChunk.clientSideActions) {
const actionsToExecute = lastChunk.clientSideActions.filter(
(action) => action.lastBubbleBlockId === blockId
)
for (const action of actionsToExecute) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
}
}
}
const handleSkip = () => sendMessage(undefined)
return (
<div
ref={chatContainer}
class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth"
>
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk
inputIndex={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={theme()}
settings={props.initialChatReply.typebot.settings}
isLoadingBubbleDisplayed={isSending()}
onNewBubbleDisplayed={handleNewBubbleDisplayed}
onAllBubblesDisplayed={handleAllBubblesDisplayed}
onSubmit={sendMessage}
onScrollToBottom={autoScrollToBottom}
onSkip={handleSkip}
context={props.context}
/>
)}
</For>
<Show when={isSending()}>
<LoadingChunk theme={theme()} />
</Show>
<Show when={blockedPopupUrl()} keyed>
{(blockedPopupUrl) => (
<div class="flex justify-end">
<PopupBlockedToast
url={blockedPopupUrl}
onLinkClick={() => setBlockedPopupUrl(undefined)}
/>
</div>
)}
</Show>
<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,23 @@
import { Theme } from '@typebot.io/schemas'
import { Show } from 'solid-js'
import { LoadingBubble } from '../bubbles/LoadingBubble'
import { AvatarSideContainer } from './AvatarSideContainer'
type Props = {
theme: Theme
}
export const LoadingChunk = (props: Props) => (
<div class="flex w-full">
<div class="flex flex-col w-full min-w-0">
<div class="flex">
<Show when={props.theme.chat.hostAvatar?.isEnabled}>
<AvatarSideContainer
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
/>
</Show>
<LoadingBubble />
</div>
</div>
</div>
)

View File

@ -0,0 +1,30 @@
type Props = {
url: string
onLinkClick: () => void
}
export const PopupBlockedToast = (props: Props) => {
return (
<div
class="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
role="alert"
>
<span class="mb-1 text-sm font-semibold text-gray-900">
Popup blocked
</span>
<div class="mb-2 text-sm font-normal">
The bot wants to open a new tab but it was blocked by your broswer. It
needs a manual approval.
</div>
<a
href={props.url}
target="_blank"
class="py-1 px-4 justify-center text-sm font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button"
rel="noreferrer"
onClick={() => props.onLinkClick()}
>
Continue in new tab
</a>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './ConversationContainer'

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-2xl text-center">{props.error.message}</p>
</div>
)
}

View File

@ -0,0 +1,195 @@
import type {
ChatReply,
ChoiceInputBlock,
DateInputOptions,
EmailInputBlock,
FileInputBlock,
NumberInputBlock,
PaymentInputOptions,
PhoneNumberInputBlock,
RatingInputBlock,
RuntimeOptions,
TextInputBlock,
Theme,
UrlInputBlock,
} from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums'
import { GuestBubble } from './bubbles/GuestBubble'
import { BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
import { UrlInput } from '@/features/blocks/inputs/url'
import { PhoneInput } from '@/features/blocks/inputs/phone'
import { DateForm } from '@/features/blocks/inputs/date'
import { ChoiceForm } from '@/features/blocks/inputs/buttons'
import { RatingForm } from '@/features/blocks/inputs/rating'
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
import { createSignal, Switch, Match } from 'solid-js'
import { isNotDefined } from '@typebot.io/lib'
import { isMobile } from '@/utils/isMobileSignal'
import { PaymentForm } from '@/features/blocks/inputs/payment'
type Props = {
block: NonNullable<ChatReply['input']>
hasHostAvatar: boolean
guestAvatar?: Theme['chat']['guestAvatar']
inputIndex: number
context: BotContext
isInputPrefillEnabled: boolean
onSubmit: (answer: string) => void
onSkip: () => void
}
export const InputChatBlock = (props: Props) => {
const [answer, setAnswer] = createSignal<string>()
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
props.onSubmit(value ?? label)
}
const handleSkip = (label: string) => {
setAnswer(label)
props.onSkip()
}
return (
<Switch>
<Match when={answer()} keyed>
{(answer) => (
<GuestBubble
message={answer}
showAvatar={props.guestAvatar?.isEnabled ?? false}
avatarSrc={props.guestAvatar?.url && props.guestAvatar.url}
/>
)}
</Match>
<Match when={isNotDefined(answer())}>
<div class="flex justify-end animate-fade-in">
{props.hasHostAvatar && (
<div
class={
'flex mr-2 mb-2 mt-1 flex-shrink-0 items-center ' +
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
}
/>
)}
<Input
context={props.context}
block={props.block}
inputIndex={props.inputIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled}
onSubmit={handleSubmit}
onSkip={handleSkip}
/>
</div>
</Match>
</Switch>
)
}
const Input = (props: {
context: BotContext
block: NonNullable<ChatReply['input']>
inputIndex: number
isInputPrefillEnabled: boolean
onSubmit: (answer: InputSubmitContent) => void
onSkip: (label: string) => void
}) => {
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
const getPrefilledValue = () =>
props.isInputPrefillEnabled ? props.block.prefilledValue : undefined
const submitPaymentSuccess = () =>
props.onSubmit({
value:
(props.block.options as PaymentInputOptions).labels.success ??
'Success',
})
return (
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
labels={(props.block as PhoneNumberInputBlock).options.labels}
defaultCountryCode={
(props.block as PhoneNumberInputBlock).options.defaultCountryCode
}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.DATE}>
<DateForm
options={props.block.options as DateInputOptions}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.CHOICE}>
<ChoiceForm
inputIndex={props.inputIndex}
block={props.block as ChoiceInputBlock}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.RATING}>
<RatingForm
block={props.block as RatingInputBlock}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.FILE}>
<FileUploadForm
context={props.context}
block={props.block as FileInputBlock}
onSubmit={onSubmit}
onSkip={props.onSkip}
/>
</Match>
<Match when={props.block.type === InputBlockType.PAYMENT}>
<PaymentForm
context={props.context}
options={
{
...props.block.options,
...props.block.runtimeOptions,
} as PaymentInputOptions & RuntimeOptions
}
onSuccess={submitPaymentSuccess}
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1,53 @@
import { onCleanup, onMount } from 'solid-js'
import { TypebotLogo } from './icons/TypebotLogo'
type Props = {
botContainer: HTMLDivElement | undefined
}
export const LiteBadge = (props: Props) => {
let liteBadge: HTMLAnchorElement | undefined
let observer: MutationObserver | undefined
const appendBadgeIfNecessary = (mutations: MutationRecord[]) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((removedNode) => {
if (
'id' in removedNode &&
liteBadge &&
removedNode.id == 'lite-badge'
) {
console.log("Sorry, you can't remove the brand 😅")
props.botContainer?.append(liteBadge)
}
})
})
}
onMount(() => {
if (!document || !props.botContainer) return
observer = new MutationObserver(appendBadgeIfNecessary)
observer.observe(props.botContainer, {
subtree: false,
childList: true,
})
})
onCleanup(() => {
if (observer) observer.disconnect()
})
return (
<a
ref={liteBadge}
href={'https://www.typebot.io/?utm_source=litebadge'}
target="_blank"
rel="noopener noreferrer"
class="lite-badge"
id="lite-badge"
>
<TypebotLogo />
<span>Made with Typebot</span>
</a>
)
}

View File

@ -0,0 +1,59 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
import { SendIcon } from './icons'
type SendButtonProps = {
isDisabled?: boolean
isLoading?: boolean
disableIcon?: boolean
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = (props: SendButtonProps) => {
return (
<button
type="submit"
disabled={props.isDisabled || props.isLoading}
{...props}
class={
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button ' +
props.class
}
>
<Show when={!props.isLoading} fallback={<Spinner class="text-white" />}>
{isMobile() && !props.disableIcon ? (
<SendIcon
class={'send-icon flex ' + (props.disableIcon ? 'hidden' : '')}
/>
) : (
props.children
)}
</Show>
</button>
)
}
export const Spinner = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
{...props}
class={'animate-spin -ml-1 mr-3 h-5 w-5 ' + props.class}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
data-testid="loading-spinner"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)

View File

@ -0,0 +1,7 @@
export const TypingBubble = () => (
<div class="flex items-center">
<div class="w-2 h-2 mr-1 rounded-full bubble1" />
<div class="w-2 h-2 mr-1 rounded-full bubble2" />
<div class="w-2 h-2 rounded-full bubble3" />
</div>
)

View File

@ -0,0 +1,37 @@
import { isMobile } from '@/utils/isMobileSignal'
import { createEffect, createSignal, Show } from 'solid-js'
import { isNotEmpty } from '@typebot.io/lib'
import { DefaultAvatar } from './DefaultAvatar'
export const Avatar = (props: { initialAvatarSrc?: string }) => {
const [avatarSrc, setAvatarSrc] = createSignal(props.initialAvatarSrc)
createEffect(() => {
if (
avatarSrc()?.startsWith('{{') &&
props.initialAvatarSrc?.startsWith('http')
)
setAvatarSrc(props.initialAvatarSrc)
})
return (
<Show
when={isNotEmpty(avatarSrc())}
keyed
fallback={() => <DefaultAvatar />}
>
<figure
class={
'flex justify-center items-center rounded-full text-white relative animate-fade-in flex-shrink-0 ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
>
<img
src={avatarSrc()}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
</Show>
)
}

View File

@ -0,0 +1,54 @@
import { isMobile } from '@/utils/isMobileSignal'
export const DefaultAvatar = () => {
return (
<figure
class={
'flex justify-center items-center rounded-full text-white relative ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
data-testid="default-avatar"
>
<svg
width="75"
height="75"
viewBox="0 0 75 75"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={
'absolute top-0 left-0 ' +
(isMobile() ? ' w-6 h-6 text-sm' : 'w-full h-full text-xl')
}
>
<mask id="mask0" x="0" y="0" mask-type="alpha">
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
</mask>
<g mask="url(#mask0)">
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
<rect
x="2.50413"
y="120.333"
width="81.5597"
height="86.4577"
rx="2.5"
transform="rotate(-52.6423 2.50413 120.333)"
stroke="#FED23D"
stroke-width="5"
/>
<circle
cx="76.5"
cy="-1.5"
r="29"
stroke="#FF8E20"
stroke-width="5"
/>
<path
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
stroke="#F7F8FF"
stroke-width="5"
/>
</g>
</svg>
</figure>
)
}

View File

@ -0,0 +1,25 @@
import { Show } from 'solid-js'
import { Avatar } from '../avatars/Avatar'
type Props = {
message: string
showAvatar: boolean
avatarSrc?: string
}
export const GuestBubble = (props: Props) => (
<div
class="flex justify-end mb-2 items-end animate-fade-in"
style={{ 'margin-left': '50px' }}
>
<span
class="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
data-testid="guest-bubble"
>
{props.message}
</span>
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)

View File

@ -0,0 +1,64 @@
import { AudioBubble } from '@/features/blocks/bubbles/audio'
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import type {
AudioBubbleContent,
ChatMessage,
EmbedBubbleContent,
ImageBubbleContent,
TextBubbleContent,
TypingEmulation,
VideoBubbleContent,
} from '@typebot.io/schemas'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/enums'
import { Match, Switch } from 'solid-js'
type Props = {
message: ChatMessage
typingEmulation: TypingEmulation
onTransitionEnd: () => void
}
export const HostBubble = (props: Props) => {
const onTransitionEnd = () => {
props.onTransitionEnd()
}
return (
<Switch>
<Match when={props.message.type === BubbleBlockType.TEXT}>
<TextBubble
content={props.message.content as Omit<TextBubbleContent, 'richText'>}
typingEmulation={props.typingEmulation}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.IMAGE}>
<ImageBubble
url={(props.message.content as ImageBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.VIDEO}>
<VideoBubble
content={props.message.content as VideoBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.EMBED}>
<EmbedBubble
content={props.message.content as EmbedBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.AUDIO}>
<AudioBubble
url={(props.message.content as AudioBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1,25 @@
import { TypingBubble } from '@/components'
export const LoadingBubble = () => (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing "
style={{
width: '64px',
height: '32px',
}}
data-testid="host-bubble"
>
<TypingBubble />
</div>
<p
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative opacity-0 h-6 text-ellipsis'
}
/>
</div>
</div>
</div>
)

View File

@ -0,0 +1,16 @@
import { JSX } from 'solid-js/jsx-runtime'
export const ChevronDownIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2px"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<polyline points="6 9 12 15 18 9" />
</svg>
)

View File

@ -0,0 +1,13 @@
import { JSX } from 'solid-js/jsx-runtime'
export const SendIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width="19px"
color="white"
{...props}
>
<path d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z" />
</svg>
)

View File

@ -0,0 +1,37 @@
export const TypebotLogo = () => {
return (
<svg viewBox="0 0 800 800" width={16}>
<rect width="800" height="800" rx="80" fill="#0042DA" />
<rect
x="650"
y="293"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(90 650 293)"
fill="#FF8E20"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
fill="#FF8E20"
/>
<rect
x="150"
y="506.677"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(-90 150 506.677)"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
fill="white"
/>
</svg>
)
}

View File

@ -0,0 +1 @@
export * from './SendIcon'

View File

@ -0,0 +1,3 @@
export * from './SendButton'
export * from './TypingBubble'
export * from './inputs'

View File

@ -0,0 +1,22 @@
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
type ShortTextInputProps = {
ref: HTMLInputElement | undefined
onInput: (value: string) => void
} & Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput'>
export const ShortTextInput = (props: ShortTextInputProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<input
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' }}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>
)
}

View File

@ -0,0 +1,26 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
type TextareaProps = {
ref: HTMLTextAreaElement | undefined
onInput: (value: string) => void
} & Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput'>
export const Textarea = (props: TextareaProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<textarea
ref={local.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
rows={6}
data-testid="textarea"
required
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>
)
}

View File

@ -0,0 +1,2 @@
export * from './ShortTextInput'
export * from './Textarea'

View File

@ -0,0 +1,35 @@
import type { BubbleProps } from './features/bubble'
import type { PopupProps } from './features/popup'
import type { BotProps } from './components/Bot'
export const defaultBotProps: BotProps = {
typebot: '',
onNewInputBlock: undefined,
onAnswer: undefined,
onEnd: undefined,
onInit: undefined,
onNewLogs: 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,
}

10
packages/embeds/js/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
export {}
declare module 'solid-js' {
namespace JSX {
interface CustomEvents {
click: MouseEvent
pointerdown: PointerEvent
}
}
}

View File

@ -0,0 +1,58 @@
import { TypingBubble } from '@/components'
import type { AudioBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
url: AudioBubbleContent['url']
onTransitionEnd: () => void
}
const showAnimationDuration = 400
const typingDuration = 500
let typingTimeout: NodeJS.Timeout
export const AudioBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, typingDuration)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<audio
src={props.url}
class={
'z-10 text-fade-in m-2 ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{ height: isTyping() ? '32px' : 'revert' }}
autoplay
controls
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './AudioBubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,60 @@
import { TypingBubble } from '@/components'
import type { EmbedBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
content: EmbedBubbleContent
onTransitionEnd: () => void
}
let typingTimeout: NodeJS.Timeout
export const showAnimationDuration = 400
export const EmbedBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, 2000)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col w-full animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div
class={'flex relative z-10 items-start typebot-host-bubble w-full'}
>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<iframe
id="embed-bubble-content"
src={props.content.url}
class={
'w-full z-20 p-4 text-fade-in rounded-2xl ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping() ? '32px' : `${props.content.height}px`,
}}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './EmbedBubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,73 @@
import { TypingBubble } from '@/components'
import type { ImageBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
url: ImageBubbleContent['url']
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
let typingTimeout: NodeJS.Timeout
export const ImageBubble = (props: Props) => {
let image: HTMLImageElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!image) return
typingTimeout = setTimeout(onTypingEnd, mediaLoadingFallbackTimeout)
image.onload = () => {
clearTimeout(typingTimeout)
onTypingEnd()
}
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() ? <TypingBubble /> : null}
</div>
<figure class="p-4 z-10">
<img
ref={image}
src={props.url}
class={
'text-fade-in w-full rounded-md ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
'max-height': '512px',
height: isTyping() ? '32px' : 'auto',
}}
alt="Bubble image"
/>
</figure>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { ImageBubble } from './components/ImageBubble'

View File

@ -0,0 +1,74 @@
import { TypingBubble } from '@/components'
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { computeTypingDuration } from '../utils/computeTypingDuration'
type Props = {
content: Pick<TextBubbleContent, 'html' | 'plainText'>
typingEmulation: TypingEmulation
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
let typingTimeout: NodeJS.Timeout
export const TextBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!isTyping) return
const typingDuration =
props.typingEmulation?.enabled === false
? 0
: computeTypingDuration(
props.content.plainText,
props.typingEmulation ?? defaultTypingEmulation
)
typingTimeout = setTimeout(onTypingEnd, typingDuration)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
data-testid="host-bubble"
>
{isTyping() && <TypingBubble />}
</div>
<p
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis ' +
(isTyping() ? 'opacity-0 h-6' : 'opacity-100 h-full')
}
innerHTML={props.content.html}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { TextBubble } from './components/TextBubble'

View File

@ -0,0 +1,16 @@
import type { TypingEmulation } from '@typebot.io/schemas'
export const computeTypingDuration = (
bubbleContent: string,
typingSettings: TypingEmulation
) => {
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
if (wordCount === 0) wordCount = bubbleContent.length
const typedWordsPerMinute = typingSettings.speed
let typingTimeout = typingSettings.enabled
? (wordCount / typedWordsPerMinute) * 60000
: 0
if (typingTimeout > typingSettings.maxDelay * 1000)
typingTimeout = typingSettings.maxDelay * 1000
return typingTimeout
}

View File

@ -0,0 +1,110 @@
import { TypingBubble } from '@/components'
import type { VideoBubbleContent } from '@typebot.io/schemas'
import { VideoBubbleContentType } from '@typebot.io/schemas/features/blocks/bubbles/video/enums'
import { createSignal, Match, onCleanup, onMount, Switch } from 'solid-js'
type Props = {
content: VideoBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
let typingTimeout: NodeJS.Timeout
export const VideoBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
typingTimeout = setTimeout(onTypingEnd, 2000)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<VideoContent content={props.content} isTyping={isTyping()} />
</div>
</div>
</div>
)
}
type VideoContentProps = {
content: VideoBubbleContent
isTyping: boolean
}
const VideoContent = (props: VideoContentProps) => {
return (
<Switch>
<Match
when={
props.content?.type &&
props.content.type === VideoBubbleContentType.URL
}
>
<video
controls
class={
'p-4 focus:outline-none w-full z-10 text-fade-in rounded-md ' +
(props.isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: props.isTyping ? '32px' : 'auto',
'max-height': window.navigator.vendor.match(/apple/i) ? '40vh' : '',
}}
autoplay
>
<source src={props.content.url} type="video/mp4" />
Sorry, your browser doesn&apos;t support embedded videos.
</video>
</Match>
<Match
when={
props.content?.type &&
[
VideoBubbleContentType.VIMEO,
VideoBubbleContentType.YOUTUBE,
].includes(props.content.type)
}
>
<iframe
src={`${
props.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
}/${props.content.id}`}
class={
'w-full p-4 text-fade-in z-10 rounded-md ' +
(props.isTyping ? 'opacity-0' : 'opacity-100')
}
height={props.isTyping ? '32px' : '200px'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1 @@
export { VideoBubble } from './components/VideoBubble'

View File

@ -0,0 +1,82 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { ChoiceInputBlock } from '@typebot.io/schemas'
import { createSignal, For } from 'solid-js'
type Props = {
inputIndex: number
block: ChoiceInputBlock
onSubmit: (value: InputSubmitContent) => void
}
export const ChoiceForm = (props: Props) => {
const [selectedIndices, setSelectedIndices] = createSignal<number[]>([])
const handleClick = (itemIndex: number) => {
if (props.block.options?.isMultipleChoice)
toggleSelectedItemIndex(itemIndex)
else props.onSubmit({ value: props.block.items[itemIndex].content ?? '' })
}
const toggleSelectedItemIndex = (itemIndex: number) => {
const existingIndex = selectedIndices().indexOf(itemIndex)
if (existingIndex !== -1) {
setSelectedIndices((selectedIndices) =>
selectedIndices.filter((index) => index !== itemIndex)
)
} else {
setSelectedIndices((selectedIndices) => [...selectedIndices, itemIndex])
}
}
const handleSubmit = () =>
props.onSubmit({
value: selectedIndices()
.map((itemIndex) => props.block.items[itemIndex].content)
.join(', '),
})
return (
<form class="flex flex-col items-end" onSubmit={handleSubmit}>
<div class="flex flex-wrap justify-end">
<For each={props.block.items}>
{(item, index) => (
<span class="relative inline-flex ml-2 mb-2">
<button
role={
props.block.options?.isMultipleChoice ? 'checkbox' : 'button'
}
type="button"
on:click={() => handleClick(index())}
class={
'py-2 px-4 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(selectedIndices().some(
(selectedIndex) => selectedIndex === index()
) || !props.block.options?.isMultipleChoice
? ''
: 'selectable')
}
data-itemid={item.id}
>
{item.content}
</button>
{props.inputIndex === 0 && props.block.items.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
</span>
)}
</span>
)}
</For>
</div>
<div class="flex">
{selectedIndices().length > 0 && (
<SendButton disableIcon>
{props.block.options?.buttonLabel ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}

View File

@ -0,0 +1 @@
export { ChoiceForm } from './components/ChoiceForm'

View File

@ -0,0 +1,112 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { DateInputOptions } from '@typebot.io/schemas'
import { createSignal } from 'solid-js'
import { parseReadableDate } from '../utils/parseReadableDate'
type Props = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
defaultValue?: string
}
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal(
parseDefaultValue(props.defaultValue ?? '')
)
return (
<div class="flex flex-col">
<div class="flex items-center">
<form
class={'flex justify-between rounded-lg typebot-input pr-2 items-end'}
onSubmit={(e) => {
if (inputValues().from === '' && inputValues().to === '') return
e.preventDefault()
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
label: parseReadableDate({
...inputValues(),
hasTime: props.options?.hasTime,
isRange: props.options?.isRange,
}),
})
}}
>
<div class="flex flex-col">
<div
class={
'flex items-center p-4 ' +
(props.options?.isRange ? 'pb-0' : '')
}
>
{props.options?.isRange && (
<p class="font-semibold mr-2">
{props.options.labels?.from ?? 'From:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().from}
type={props.options?.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
from: e.currentTarget.value,
})
}
data-testid="from-date"
/>
</div>
{props.options?.isRange && (
<div class="flex items-center p-4">
{props.options.isRange && (
<p class="font-semibold">
{props.options.labels?.to ?? 'To:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().to}
type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
to: e.currentTarget.value,
})
}
data-testid="to-date"
/>
</div>
)}
</div>
<SendButton
isDisabled={inputValues().to === '' && inputValues().from === ''}
class="my-2 ml-2"
>
{props.options?.labels?.button ?? 'Send'}
</SendButton>
</form>
</div>
</div>
)
}
const parseDefaultValue = (defaultValue: string) => {
if (!defaultValue.includes('to')) return { from: defaultValue, to: '' }
const [from, to] = defaultValue.split(' to ')
return { from, to }
}

View File

@ -0,0 +1 @@
export { DateForm } from './components/DateForm'

View File

@ -0,0 +1,27 @@
export const parseReadableDate = ({
from,
to,
hasTime,
isRange,
}: {
from: string
to: string
hasTime?: boolean
isRange?: boolean
}) => {
const currentLocale = window.navigator.language
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: hasTime ? '2-digit' : undefined,
minute: hasTime ? '2-digit' : undefined,
}
const fromReadable = new Date(
hasTime ? from : from.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
const toReadable = new Date(
hasTime ? to : to.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@ -0,0 +1,66 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { EmailInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: EmailInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your email...'
}
onInput={handleInput}
type="email"
autocomplete="email"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

@ -0,0 +1 @@
export { EmailInput } from './components/EmailInput'

View File

@ -0,0 +1,272 @@
import { SendButton, Spinner } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { guessApiHost } from '@/utils/guessApiHost'
import { FileInputBlock } from '@typebot.io/schemas'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from '@typebot.io/lib'
type Props = {
context: BotContext
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: (label: string) => void
}
export const FileUploadForm = (props: Props) => {
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
const [isUploading, setIsUploading] = createSignal(false)
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
const [errorMessage, setErrorMessage] = createSignal<string>()
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
if (
newFiles.some(
(file) =>
file.size > (props.block.options.sizeLimit ?? 10) * 1024 * 1024
)
)
return setErrorMessage(
`A file is larger than ${props.block.options.sizeLimit ?? 10}MB`
)
if (!props.block.options.isMultipleAllowed && files)
return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles(), ...newFiles])
}
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
if (selectedFiles().length === 0) return
startFilesUpload(selectedFiles())
}
const startSingleFileUpload = async (file: File) => {
if (props.context.isPreview)
return props.onSubmit({
label: `File uploaded`,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
props.context.typebotId
}/blocks/${props.block.id}`,
files: [
{
file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
},
],
})
setIsUploading(false)
if (urls.length)
return props.onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
if (props.context.isPreview)
return props.onSubmit({
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
value: files
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
.join(', '),
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
props.context.typebotId
}/blocks/${props.block.id}`,
files: files.map((file) => ({
file: file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
})),
onUploadProgress: setUploadProgressPercent,
})
setIsUploading(false)
setUploadProgressPercent(0)
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
props.onSubmit({
label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`,
value: urls.join(', '),
})
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDraggingOver(true)
}
const handleDragLeave = () => setIsDraggingOver(false)
const handleDropFile = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer?.files) return
onNewFiles(e.dataTransfer.files)
}
const clearFiles = () => setSelectedFiles([])
return (
<form class="flex flex-col w-full" onSubmit={handleSubmit}>
<label
for="dropzone-file"
class={
'typebot-upload-input py-6 flex flex-col justify-center items-center w-full bg-gray-50 rounded-lg border-2 border-gray-300 border-dashed cursor-pointer hover:bg-gray-100 px-8 mb-2 ' +
(isDraggingOver() ? 'dragging-over' : '')
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDropFile}
>
<Switch>
<Match when={isUploading()}>
<Show when={selectedFiles().length > 1} fallback={<Spinner />}>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="upload-progress-bar h-2.5 rounded-full"
style={{
width: `${
uploadProgressPercent() > 0 ? uploadProgressPercent : 10
}%`,
transition: 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
</Show>
</Match>
<Match when={!isUploading()}>
<>
<div class="flex flex-col justify-center items-center">
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
<span class="relative">
<FileIcon />
<div
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles().length}
</div>
</span>
</Show>
<p
class="text-sm text-gray-500 text-center"
innerHTML={props.block.options.labels.placeholder}
/>
</div>
<input
id="dropzone-file"
type="file"
class="hidden"
multiple={props.block.options.isMultipleAllowed}
onChange={(e) => {
if (!e.currentTarget.files) return
onNewFiles(e.currentTarget.files)
}}
/>
</>
</Match>
</Switch>
</label>
<Show
when={
selectedFiles().length === 0 &&
props.block.options.isRequired === false
}
>
<div class="flex justify-end">
<button
class={
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button '
}
on:click={() =>
props.onSkip(
props.block.options.labels.skip ??
defaultFileInputOptions.labels.skip
)
}
>
{props.block.options.labels.skip ??
defaultFileInputOptions.labels.skip}
</button>
</div>
</Show>
<Show
when={
props.block.options.isMultipleAllowed &&
selectedFiles().length > 0 &&
!isUploading()
}
>
<div class="flex justify-end">
<div class="flex">
<Show when={selectedFiles().length}>
<button
class={
'secondary-button py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 mr-2'
}
on:click={clearFiles}
>
{props.block.options.labels.clear ??
defaultFileInputOptions.labels.clear}
</button>
</Show>
<SendButton type="submit" disableIcon>
{props.block.options.labels.button ===
defaultFileInputOptions.labels.button
? `Upload ${selectedFiles().length} file${
selectedFiles().length > 1 ? 's' : ''
}`
: props.block.options.labels.button}
</SendButton>
</div>
</div>
</Show>
<Show when={errorMessage()}>
<p class="text-red-500 text-sm">{errorMessage()}</p>
</Show>
</form>
)
}
const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
>
<polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" />
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
<polyline points="16 16 12 12 8 16" />
</svg>
)
const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)

View File

@ -0,0 +1 @@
export { FileUploadForm } from './components/FileUploadForm'

View File

@ -0,0 +1,69 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { NumberInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
onInput={handleInput}
type="number"
style={{ appearance: 'auto' }}
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? 'any'}
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

@ -0,0 +1 @@
export { NumberInput } from './components/NumberInput'

View File

@ -0,0 +1,23 @@
import { BotContext } from '@/types'
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
import { PaymentProvider } from '@typebot.io/schemas/features/blocks/inputs/payment/enums'
import { Match, Switch } from 'solid-js'
import { StripePaymentForm } from './StripePaymentForm'
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
export const PaymentForm = (props: Props) => (
<Switch>
<Match when={props.options.provider === PaymentProvider.STRIPE}>
<StripePaymentForm
onSuccess={props.onSuccess}
options={props.options}
context={props.context}
/>
</Match>
</Switch>
)

View File

@ -0,0 +1,112 @@
import { SendButton } from '@/components/SendButton'
import { createSignal, onMount, Show } from 'solid-js'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import { BotContext } from '@/types'
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
import { loadStripe } from '@/lib/stripe'
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
const slotName = 'stripe-payment-form'
let paymentElementSlot: HTMLSlotElement
let stripe: Stripe | null = null
let elements: StripeElements | null = null
export const StripePaymentForm = (props: Props) => {
const [message, setMessage] = createSignal<string>()
const [isMounted, setIsMounted] = createSignal(false)
const [isLoading, setIsLoading] = createSignal(false)
onMount(async () => {
initShadowMountPoint(paymentElementSlot)
stripe = await loadStripe(props.options.publicKey)
if (!stripe) return
elements = stripe.elements({
appearance: {
theme: 'stripe',
variables: {
colorPrimary: getComputedStyle(paymentElementSlot).getPropertyValue(
'--typebot-button-bg-color'
),
},
},
clientSecret: props.options.paymentIntentSecret,
})
const paymentElement = elements.create('payment', {
layout: 'tabs',
})
paymentElement.mount('#payment-element')
setTimeout(() => setIsMounted(true), 1000)
})
const handleSubmit = async (event: Event & { submitter: HTMLElement }) => {
event.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
// TO-DO: Handle redirection correctly.
return_url: props.context.apiHost,
payment_method_data: {
billing_details: {
name: props.options.additionalInformation?.name,
email: props.options.additionalInformation?.email,
phone: props.options.additionalInformation?.phoneNumber,
},
},
},
redirect: 'if_required',
})
setIsLoading(false)
if (error?.type === 'validation_error') return
if (error?.type === 'card_error') return setMessage(error.message)
if (!error && paymentIntent.status === 'succeeded') return props.onSuccess()
}
return (
<form
id="payment-form"
onSubmit={handleSubmit}
class="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
>
<slot name={slotName} ref={paymentElementSlot} />
<Show when={isMounted()}>
<SendButton
isLoading={isLoading()}
class="mt-4 w-full max-w-lg animate-fade-in"
disableIcon
>
{props.options.labels.button} {props.options.amountLabel}
</SendButton>
</Show>
<Show when={message()}>
<div class="typebot-input-error-message mt-4 text-center animate-fade-in">
{message()}
</div>
</Show>
</form>
)
}
const initShadowMountPoint = (element: HTMLElement) => {
const rootNode = element.getRootNode() as ShadowRoot
const host = rootNode.host
const slotPlaceholder = document.createElement('div')
slotPlaceholder.style.width = '100%'
slotPlaceholder.slot = slotName
host.appendChild(slotPlaceholder)
const paymentElementContainer = document.createElement('div')
paymentElementContainer.id = 'payment-element'
slotPlaceholder.appendChild(paymentElementContainer)
}

View File

@ -0,0 +1 @@
export * from './PaymentForm'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,152 @@
import { ShortTextInput } from '@/components'
import { ChevronDownIcon } from '@/components/icons/ChevronDownIcon'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { PhoneNumberInputOptions } from '@typebot.io/schemas'
import { createSignal, For, onMount } from 'solid-js'
import { isEmpty } from '@typebot.io/lib'
import { phoneCountries } from '@typebot.io/lib/phoneCountries'
type PhoneInputProps = Pick<
PhoneNumberInputOptions,
'labels' | 'defaultCountryCode'
> & {
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const PhoneInput = (props: PhoneInputProps) => {
const [selectedCountryCode, setSelectedCountryCode] = createSignal(
isEmpty(props.defaultCountryCode) ? 'INT' : props.defaultCountryCode
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string | undefined) => {
setInputValue(inputValue as string)
if (
(inputValue === '' || inputValue === '+') &&
selectedCountryCode() !== 'INT'
)
setSelectedCountryCode('INT')
const matchedCountry =
inputValue?.startsWith('+') &&
inputValue.length > 2 &&
phoneCountries.reduce<typeof phoneCountries[number] | null>(
(matchedCountry, country) => {
if (
!country?.dial_code ||
(matchedCountry !== null && !matchedCountry.dial_code)
) {
return matchedCountry
}
if (
inputValue?.startsWith(country.dial_code) &&
country.dial_code.length > (matchedCountry?.dial_code.length ?? 0)
) {
return country
}
return matchedCountry
},
null
)
if (matchedCountry) setSelectedCountryCode(matchedCountry.code)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
const selectedCountryDialCode = phoneCountries.find(
(country) => country.code === selectedCountryCode()
)?.dial_code
if (checkIfInputIsValid())
props.onSubmit({
value: inputValue().startsWith('+')
? inputValue()
: `${selectedCountryDialCode ?? ''}${inputValue()}`,
})
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
const selectNewCountryCode = (
event: Event & { currentTarget: { value: string } }
) => {
const code = event.currentTarget.value
setSelectedCountryCode(code)
const dial_code = phoneCountries.find(
(country) => country.code === code
)?.dial_code
if (inputValue() === '' && dial_code) setInputValue(dial_code)
inputRef?.focus()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={'flex items-end justify-between rounded-lg pr-2 typebot-input'}
data-testid="input"
style={{
'max-width': '400px',
}}
onKeyDown={submitWhenEnter}
>
<div class="flex">
<div class="relative typebot-country-select flex justify-center items-center rounded-md">
<div class="pl-2 pr-1 flex items-center gap-2">
<span>
{
phoneCountries.find(
(country) => selectedCountryCode() === country.code
)?.flag
}
</span>
<ChevronDownIcon class="w-3" />
</div>
<select
onChange={selectNewCountryCode}
class="absolute top-0 left-0 w-full h-full cursor-pointer opacity-0"
>
<For each={phoneCountries}>
{(country) => (
<option
value={country.code}
selected={country.code === selectedCountryCode()}
>
{country.name}{' '}
{country.dial_code ? `(${country.dial_code})` : ''}
</option>
)}
</For>
</select>
</div>
<ShortTextInput
type="tel"
ref={inputRef}
value={inputValue()}
onInput={handleInput}
placeholder={props.labels.placeholder ?? 'Your phone number...'}
autofocus={!isMobile()}
/>
</div>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

@ -0,0 +1 @@
export { PhoneInput } from './components/PhoneInput'

View File

@ -0,0 +1,122 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { RatingInputBlock, RatingInputOptions } from '@typebot.io/schemas'
import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
type Props = {
block: RatingInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
props.defaultValue ? Number(props.defaultValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
const selectedRating = rating()
if (isNotDefined(selectedRating)) return
props.onSubmit({ value: selectedRating.toString() })
}
const handleClick = (rating: number) => {
if (props.block.options.isOneClickSubmitEnabled)
props.onSubmit({ value: rating.toString() })
setRating(rating)
}
return (
<form class="flex flex-col" onSubmit={handleSubmit}>
{props.block.options.labels.left && (
<span class="text-sm w-full mb-2 rating-label">
{props.block.options.labels.left}
</span>
)}
<div class="flex flex-wrap justify-center">
<For
each={Array.from(
Array(
props.block.options.length +
(props.block.options.buttonType === 'Numbers' ? 1 : 0)
)
)}
>
{(_, idx) => (
<RatingButton
{...props.block.options}
rating={rating()}
idx={
idx() + (props.block.options.buttonType === 'Numbers' ? 0 : 1)
}
onClick={handleClick}
/>
)}
</For>
</div>
{props.block.options.labels.right && (
<span class="text-sm w-full text-right mb-2 pr-2 rating-label">
{props.block.options.labels.right}
</span>
)}
<div class="flex justify-end mr-2">
{isDefined(rating()) && (
<SendButton disableIcon>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}
type RatingButtonProps = {
rating?: number
idx: number
onClick: (rating: number) => void
} & RatingInputOptions
const RatingButton = (props: RatingButtonProps) => {
return (
<Switch>
<Match when={props.buttonType === 'Numbers'}>
<button
on:click={(e) => {
e.preventDefault()
props.onClick(props.idx)
}}
class={
'py-2 px-4 mr-2 mb-2 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(props.isOneClickSubmitEnabled ||
(isDefined(props.rating) && props.idx <= props.rating)
? ''
: 'selectable')
}
>
{props.idx}
</button>
</Match>
<Match when={props.buttonType !== 'Numbers'}>
<div
class={
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
(isDefined(props.rating) && props.idx <= props.rating
? 'selected'
: '')
}
innerHTML={
props.customIcon.isEnabled && !isEmpty(props.customIcon.svg)
? props.customIcon.svg
: defaultIcon
}
on:click={() => props.onClick(props.idx)}
/>
</Match>
</Switch>
)
}
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`

View File

@ -0,0 +1 @@
export { RatingForm } from './components/RatingForm'

View File

@ -0,0 +1,76 @@
import { Textarea, ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { TextInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: TextInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (props.block.options.isLong) return
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': props.block.options.isLong ? undefined : '350px',
}}
onKeyDown={submitWhenEnter}
>
{props.block.options.isLong ? (
<Textarea
ref={inputRef as HTMLTextAreaElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
) : (
<ShortTextInput
ref={inputRef as HTMLInputElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
)}
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

@ -0,0 +1 @@
export { TextInput } from './components/TextInput'

View File

@ -0,0 +1,72 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { UrlInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: UrlInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => {
if (!inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as HTMLInputElement}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your URL...'
}
onInput={handleInput}
type="url"
autocomplete="url"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

@ -0,0 +1 @@
export { UrlInput } from './components/UrlInput'

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,8 @@
import { executeScript } from '@/features/blocks/logic/script/executeScript'
import type { ScriptToExecute } from '@typebot.io/schemas'
export const executeChatwoot = (chatwoot: {
scriptToExecute: ScriptToExecute
}) => {
executeScript(chatwoot.scriptToExecute)
}

View File

@ -0,0 +1 @@
export * from './executeChatwoot'

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,11 @@
import { sendGaEvent } from '@/lib/gtag'
import type { GoogleAnalyticsOptions } from '@typebot.io/schemas'
export const executeGoogleAnalyticsBlock = async (
options: GoogleAnalyticsOptions
) => {
if (!options?.trackingId) return
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
await initGoogleAnalytics(options.trackingId)
sendGaEvent(options)
}

View File

@ -0,0 +1 @@
export * from './executeGoogleAnalytics'

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,13 @@
import type { RedirectOptions } from '@typebot.io/schemas'
export const executeRedirect = ({
url,
isNewTab,
}: RedirectOptions): { blockedPopupUrl: string } | undefined => {
if (!url) return
const updatedWindow = window.open(url, isNewTab ? '_blank' : '_self')
if (!updatedWindow)
return {
blockedPopupUrl: url,
}
}

View File

@ -0,0 +1 @@
export * from './executeRedirect'

View File

@ -0,0 +1,17 @@
import type { ScriptToExecute } from '@typebot.io/schemas'
export const executeScript = async ({ content, args }: ScriptToExecute) => {
const func = Function(...args.map((arg) => arg.id), parseContent(content))
try {
await func(...args.map((arg) => arg.value))
} catch (err) {
console.error(err)
}
}
const parseContent = (content: string) => {
const contentWithoutScriptTags = content
.replace(/<script>/g, '')
.replace(/<\/script>/g, '')
return contentWithoutScriptTags
}

View File

@ -0,0 +1,7 @@
type Props = {
secondsToWaitFor: number
}
export const executeWait = async ({ secondsToWaitFor }: Props) => {
await new Promise((resolve) => setTimeout(resolve, secondsToWaitFor * 1000))
}

View File

@ -0,0 +1,160 @@
import {
createSignal,
onMount,
Show,
splitProps,
onCleanup,
createEffect,
} from 'solid-js'
import styles from '../../../assets/index.css'
import { CommandData } from '../../commands'
import { BubbleButton } from './BubbleButton'
import { PreviewMessage, PreviewMessageProps } from './PreviewMessage'
import { isDefined } from '@typebot.io/lib'
import { BubbleParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
export type BubbleProps = BotProps &
BubbleParams & {
onOpen?: () => void
onClose?: () => void
onPreviewMessageClick?: () => void
}
export const Bubble = (props: BubbleProps) => {
const [bubbleProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'previewMessage',
'onPreviewMessageClick',
'theme',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isPreviewMessageDisplayed, setIsPreviewMessageDisplayed] =
createSignal(false)
const [previewMessage, setPreviewMessage] = createSignal<
Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
>({
message: bubbleProps.previewMessage?.message ?? '',
avatarUrl: bubbleProps.previewMessage?.avatarUrl,
})
const [isBotOpened, setIsBotOpened] = createSignal(false)
const [isBotStarted, setIsBotStarted] = createSignal(false)
onMount(() => {
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = bubbleProps.previewMessage?.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
showMessage()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
})
createEffect(() => {
if (!props.prefilledVariables) return
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...props.prefilledVariables,
}))
})
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'showPreviewMessage') showMessage(data.message)
if (data.command === 'hidePreviewMessage') hideMessage()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
if (!isBotStarted()) setIsBotStarted(true)
hideMessage()
setIsBotOpened(true)
if (isBotOpened()) bubbleProps.onOpen?.()
}
const closeBot = () => {
setIsBotOpened(false)
if (isBotOpened()) bubbleProps.onClose?.()
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
const handlePreviewMessageClick = () => {
bubbleProps.onPreviewMessageClick?.()
openBot()
}
const showMessage = (
previewMessage?: Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
) => {
if (previewMessage) setPreviewMessage(previewMessage)
if (isBotOpened()) return
setIsPreviewMessageDisplayed(true)
}
const hideMessage = () => {
setIsPreviewMessageDisplayed(false)
}
return (
<>
<style>{styles}</style>
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
previewMessageTheme={bubbleProps.theme?.previewMessage}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
<BubbleButton
{...bubbleProps.theme?.button}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
/>
<div
style={{
height: 'calc(100% - 80px)',
transition:
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
'transform-origin': 'bottom right',
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
'z-index': 42424242,
}}
class={
'fixed bottom-20 sm:right-4 rounded-lg w-full sm:w-[400px] max-h-[704px] ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
>
<Show when={isBotStarted()}>
<Bot
{...botProps}
prefilledVariables={prefilledVariables()}
class="rounded-lg"
/>
</Show>
</div>
</>
)
}

View File

@ -0,0 +1,65 @@
import { Show } from 'solid-js'
import { isNotDefined } from '@typebot.io/lib'
import { ButtonTheme } from '../types'
type Props = ButtonTheme & {
isBotOpened: boolean
toggleBot: () => void
}
const defaultButtonColor = '#0042DA'
const defaultIconColor = 'white'
export const BubbleButton = (props: Props) => {
return (
<button
onClick={() => props.toggleBot()}
class={
'fixed bottom-4 right-4 shadow-md w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center animate-fade-in'
}
style={{
'background-color': props.backgroundColor ?? defaultButtonColor,
'z-index': 42424242,
}}
>
<Show when={isNotDefined(props.customIconSrc)} keyed>
<svg
viewBox="0 0 24 24"
style={{
stroke: props.iconColor ?? defaultIconColor,
}}
class={
`w-7 stroke-2 fill-transparent absolute duration-200 transition ` +
(props.isBotOpened ? 'scale-0 opacity-0' : 'scale-100 opacity-100')
}
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</Show>
<Show when={props.customIconSrc}>
<img
src={props.customIconSrc}
class="w-7 h-7 rounded-full object-cover"
alt="Bubble button icon"
/>
</Show>
<svg
viewBox="0 0 24 24"
style={{ fill: props.iconColor ?? 'white' }}
class={
`w-7 absolute duration-200 transition ` +
(props.isBotOpened
? 'scale-100 rotate-0 opacity-100'
: 'scale-0 -rotate-180 opacity-0')
}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.601 8.39897C18.269 8.06702 17.7309 8.06702 17.3989 8.39897L12 13.7979L6.60099 8.39897C6.26904 8.06702 5.73086 8.06702 5.39891 8.39897C5.06696 8.73091 5.06696 9.2691 5.39891 9.60105L11.3989 15.601C11.7309 15.933 12.269 15.933 12.601 15.601L18.601 9.60105C18.9329 9.2691 18.9329 8.73091 18.601 8.39897Z"
/>
</svg>
</button>
)
}

View File

@ -0,0 +1,87 @@
import { createSignal, Show } from 'solid-js'
import { PreviewMessageParams, PreviewMessageTheme } from '../types'
export type PreviewMessageProps = Pick<
PreviewMessageParams,
'avatarUrl' | 'message'
> & {
previewMessageTheme?: PreviewMessageTheme
onClick: () => void
onCloseClick: () => void
}
const defaultBackgroundColor = '#F7F8FF'
const defaultTextColor = '#303235'
export const PreviewMessage = (props: PreviewMessageProps) => {
const [isPreviewMessageHovered, setIsPreviewMessageHovered] =
createSignal(false)
return (
<div
onClick={() => props.onClick()}
class="fixed bottom-20 right-4 max-w-[256px] rounded-md duration-200 flex items-center gap-4 shadow-md animate-fade-in cursor-pointer hover:shadow-lg p-4"
style={{
'background-color':
props.previewMessageTheme?.backgroundColor ?? defaultBackgroundColor,
color: props.previewMessageTheme?.textColor ?? defaultTextColor,
'z-index': 42424242,
}}
onMouseEnter={() => setIsPreviewMessageHovered(true)}
onMouseLeave={() => setIsPreviewMessageHovered(false)}
>
<CloseButton
isHovered={isPreviewMessageHovered()}
previewMessageTheme={props.previewMessageTheme}
onClick={props.onCloseClick}
/>
<Show when={props.avatarUrl} keyed>
{(avatarUrl) => (
<img
src={avatarUrl}
class="rounded-full w-8 h-8 object-cover"
alt="Bot avatar"
/>
)}
</Show>
<p>{props.message}</p>
</div>
)
}
const CloseButton = (props: {
isHovered: boolean
previewMessageTheme?: PreviewMessageTheme
onClick: () => void
}) => (
<button
class={
`absolute -top-2 -right-2 rounded-full w-6 h-6 p-1 hover:brightness-95 active:brightness-90 transition-all border ` +
(props.isHovered ? 'opacity-100' : 'opacity-0')
}
onClick={(e) => {
e.stopPropagation()
return props.onClick()
}}
style={{
'background-color':
props.previewMessageTheme?.closeButtonBackgroundColor ??
defaultBackgroundColor,
color:
props.previewMessageTheme?.closeButtonIconColor ?? defaultTextColor,
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)

View File

@ -0,0 +1 @@
export * from './Bubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,33 @@
export type BubbleParams = {
theme?: BubbleTheme
previewMessage?: PreviewMessageParams
}
export type BubbleTheme = {
chatWindow?: ChatWindowTheme
button?: ButtonTheme
previewMessage?: PreviewMessageTheme
}
export type ChatWindowTheme = {
backgroundColor?: string
}
export type ButtonTheme = {
backgroundColor?: string
iconColor?: string
customIconSrc?: string
}
export type PreviewMessageParams = {
avatarUrl?: string
message: string
autoShowDelay?: number
}
export type PreviewMessageTheme = {
backgroundColor?: string
textColor?: string
closeButtonBackgroundColor?: string
closeButtonIconColor?: string
}

View File

@ -0,0 +1,2 @@
export * from './types'
export * from './utils'

View File

@ -0,0 +1,21 @@
import { PreviewMessageParams } from '../bubble/types'
export type CommandData = {
isFromTypebot: boolean
} & (
| {
command: 'open' | 'toggle' | 'close' | 'hidePreviewMessage'
}
| ShowMessageCommandData
| SetPrefilledVariablesCommandData
)
export type ShowMessageCommandData = {
command: 'showPreviewMessage'
message?: Pick<PreviewMessageParams, 'avatarUrl' | 'message'>
}
export type SetPrefilledVariablesCommandData = {
command: 'setPrefilledVariables'
variables: Record<string, string | number | boolean>
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const close = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'close',
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const hidePreviewMessage = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'hidePreviewMessage',
}
window.postMessage(message)
}

View File

@ -0,0 +1,6 @@
export * from './close'
export * from './hidePreviewMessage'
export * from './open'
export * from './setPrefilledVariables'
export * from './showPreviewMessage'
export * from './toggle'

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const open = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'open',
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData } from '../types'
export const setPrefilledVariables = (
variables: Record<string, string | number | boolean>
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'setPrefilledVariables',
variables,
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData, ShowMessageCommandData } from '../types'
export const showPreviewMessage = (
proactiveMessage?: ShowMessageCommandData['message']
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'showPreviewMessage',
message: proactiveMessage,
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const toggle = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'toggle',
}
window.postMessage(message)
}

View File

@ -0,0 +1,134 @@
import styles from '../../../assets/index.css'
import {
createSignal,
onMount,
Show,
splitProps,
onCleanup,
createEffect,
} from 'solid-js'
import { CommandData } from '../../commands'
import { isDefined, isNotDefined } from '@typebot.io/lib'
import { PopupParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
export type PopupProps = BotProps &
PopupParams & {
defaultOpen?: boolean
isOpen?: boolean
onOpen?: () => void
onClose?: () => void
}
export const Popup = (props: PopupProps) => {
const [popupProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'autoShowDelay',
'theme',
'isOpen',
'defaultOpen',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isBotOpened, setIsBotOpened] = createSignal(
// eslint-disable-next-line solid/reactivity
popupProps.isOpen ?? false
)
onMount(() => {
if (popupProps.defaultOpen) openBot()
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = popupProps.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
openBot()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
})
createEffect(() => {
if (isNotDefined(props.isOpen) || props.isOpen === isBotOpened()) return
toggleBot()
})
createEffect(() => {
if (!props.prefilledVariables) return
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...props.prefilledVariables,
}))
})
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation()
}
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
setIsBotOpened(true)
popupProps.onOpen?.()
document.body.style.overflow = 'hidden'
document.addEventListener('pointerdown', closeBot)
}
const closeBot = () => {
setIsBotOpened(false)
popupProps.onClose?.()
document.body.style.overflow = 'auto'
document.removeEventListener('pointerdown', closeBot)
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
return (
<Show when={isBotOpened()}>
<style>{styles}</style>
<div
class="relative z-10"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<style>{styles}</style>
<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={{
'background-color':
props.theme?.backgroundColor ?? 'transparent',
}}
on:pointerdown={stopPropagation}
>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
</div>
</div>
</div>
</div>
</Show>
)
}

Some files were not shown because too many files have changed in this diff Show More