♻️ Re-organize workspace folders
This commit is contained in:
1
packages/embeds/js/.eslintignore
Normal file
1
packages/embeds/js/.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
/src/react/**
|
10
packages/embeds/js/.eslintrc.cjs
Normal file
10
packages/embeds/js/.eslintrc.cjs
Normal 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
2
packages/embeds/js/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
6
packages/embeds/js/.npmignore
Normal file
6
packages/embeds/js/.npmignore
Normal file
@ -0,0 +1,6 @@
|
||||
src
|
||||
.eslintignore
|
||||
.eslintrc.cjs
|
||||
rollup.config.js
|
||||
tailwind.config.cjs
|
||||
tsconfig.json
|
1
packages/embeds/js/.npmrc
Normal file
1
packages/embeds/js/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
169
packages/embeds/js/README.md
Normal file
169
packages/embeds/js/README.md
Normal 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.
|
41
packages/embeds/js/package.json
Normal file
41
packages/embeds/js/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
52
packages/embeds/js/rollup.config.js
Normal file
52
packages/embeds/js/rollup.config.js
Normal 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
|
26
packages/embeds/js/src/assets/immutable.css
Normal file
26
packages/embeds/js/src/assets/immutable.css
Normal 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;
|
||||
}
|
235
packages/embeds/js/src/assets/index.css
Normal file
235
packages/embeds/js/src/assets/index.css
Normal 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);
|
||||
}
|
211
packages/embeds/js/src/components/Bot.tsx
Normal file
211
packages/embeds/js/src/components/Bot.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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" />
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './ConversationContainer'
|
10
packages/embeds/js/src/components/ErrorMessage.tsx
Normal file
10
packages/embeds/js/src/components/ErrorMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
195
packages/embeds/js/src/components/InputChatBlock.tsx
Normal file
195
packages/embeds/js/src/components/InputChatBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
53
packages/embeds/js/src/components/LiteBadge.tsx
Normal file
53
packages/embeds/js/src/components/LiteBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
59
packages/embeds/js/src/components/SendButton.tsx
Normal file
59
packages/embeds/js/src/components/SendButton.tsx
Normal 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>
|
||||
)
|
7
packages/embeds/js/src/components/TypingBubble.tsx
Normal file
7
packages/embeds/js/src/components/TypingBubble.tsx
Normal 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>
|
||||
)
|
37
packages/embeds/js/src/components/avatars/Avatar.tsx
Normal file
37
packages/embeds/js/src/components/avatars/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
54
packages/embeds/js/src/components/avatars/DefaultAvatar.tsx
Normal file
54
packages/embeds/js/src/components/avatars/DefaultAvatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
25
packages/embeds/js/src/components/bubbles/GuestBubble.tsx
Normal file
25
packages/embeds/js/src/components/bubbles/GuestBubble.tsx
Normal 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>
|
||||
)
|
64
packages/embeds/js/src/components/bubbles/HostBubble.tsx
Normal file
64
packages/embeds/js/src/components/bubbles/HostBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
25
packages/embeds/js/src/components/bubbles/LoadingBubble.tsx
Normal file
25
packages/embeds/js/src/components/bubbles/LoadingBubble.tsx
Normal 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>
|
||||
)
|
16
packages/embeds/js/src/components/icons/ChevronDownIcon.tsx
Normal file
16
packages/embeds/js/src/components/icons/ChevronDownIcon.tsx
Normal 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>
|
||||
)
|
13
packages/embeds/js/src/components/icons/SendIcon.tsx
Normal file
13
packages/embeds/js/src/components/icons/SendIcon.tsx
Normal 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>
|
||||
)
|
37
packages/embeds/js/src/components/icons/TypebotLogo.tsx
Normal file
37
packages/embeds/js/src/components/icons/TypebotLogo.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
packages/embeds/js/src/components/icons/index.ts
Normal file
1
packages/embeds/js/src/components/icons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SendIcon'
|
3
packages/embeds/js/src/components/index.ts
Normal file
3
packages/embeds/js/src/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './SendButton'
|
||||
export * from './TypingBubble'
|
||||
export * from './inputs'
|
22
packages/embeds/js/src/components/inputs/ShortTextInput.tsx
Normal file
22
packages/embeds/js/src/components/inputs/ShortTextInput.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
26
packages/embeds/js/src/components/inputs/Textarea.tsx
Normal file
26
packages/embeds/js/src/components/inputs/Textarea.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
2
packages/embeds/js/src/components/inputs/index.ts
Normal file
2
packages/embeds/js/src/components/inputs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ShortTextInput'
|
||||
export * from './Textarea'
|
35
packages/embeds/js/src/constants.ts
Normal file
35
packages/embeds/js/src/constants.ts
Normal 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
10
packages/embeds/js/src/env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
export {}
|
||||
|
||||
declare module 'solid-js' {
|
||||
namespace JSX {
|
||||
interface CustomEvents {
|
||||
click: MouseEvent
|
||||
pointerdown: PointerEvent
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './AudioBubble'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './EmbedBubble'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ImageBubble } from './components/ImageBubble'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextBubble } from './components/TextBubble'
|
@ -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
|
||||
}
|
@ -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'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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { VideoBubble } from './components/VideoBubble'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ChoiceForm } from './components/ChoiceForm'
|
@ -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 }
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { DateForm } from './components/DateForm'
|
@ -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}` : ''}`
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { EmailInput } from './components/EmailInput'
|
@ -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>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { FileUploadForm } from './components/FileUploadForm'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { NumberInput } from './components/NumberInput'
|
@ -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>
|
||||
)
|
@ -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)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './PaymentForm'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { PhoneInput } from './components/PhoneInput'
|
@ -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>`
|
@ -0,0 +1 @@
|
||||
export { RatingForm } from './components/RatingForm'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextInput } from './components/TextInput'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { UrlInput } from './components/UrlInput'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeChatwoot'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeGoogleAnalytics'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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,
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeRedirect'
|
@ -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
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
type Props = {
|
||||
secondsToWaitFor: number
|
||||
}
|
||||
|
||||
export const executeWait = async ({ secondsToWaitFor }: Props) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, secondsToWaitFor * 1000))
|
||||
}
|
160
packages/embeds/js/src/features/bubble/components/Bubble.tsx
Normal file
160
packages/embeds/js/src/features/bubble/components/Bubble.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export * from './Bubble'
|
1
packages/embeds/js/src/features/bubble/index.ts
Normal file
1
packages/embeds/js/src/features/bubble/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
33
packages/embeds/js/src/features/bubble/types.ts
Normal file
33
packages/embeds/js/src/features/bubble/types.ts
Normal 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
|
||||
}
|
2
packages/embeds/js/src/features/commands/index.ts
Normal file
2
packages/embeds/js/src/features/commands/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './utils'
|
21
packages/embeds/js/src/features/commands/types.ts
Normal file
21
packages/embeds/js/src/features/commands/types.ts
Normal 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>
|
||||
}
|
9
packages/embeds/js/src/features/commands/utils/close.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/close.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const close = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'close',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const hidePreviewMessage = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'hidePreviewMessage',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
6
packages/embeds/js/src/features/commands/utils/index.ts
Normal file
6
packages/embeds/js/src/features/commands/utils/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './close'
|
||||
export * from './hidePreviewMessage'
|
||||
export * from './open'
|
||||
export * from './setPrefilledVariables'
|
||||
export * from './showPreviewMessage'
|
||||
export * from './toggle'
|
9
packages/embeds/js/src/features/commands/utils/open.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/open.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const open = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'open',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
9
packages/embeds/js/src/features/commands/utils/toggle.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/toggle.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const toggle = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'toggle',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
134
packages/embeds/js/src/features/popup/components/Popup.tsx
Normal file
134
packages/embeds/js/src/features/popup/components/Popup.tsx
Normal 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
Reference in New Issue
Block a user