2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

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

10
packages/js/.eslintrc.js Normal file
View File

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

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

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

16
packages/js/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Solid App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/demo/index.tsx" type="module"></script>
</body>
</html>

50
packages/js/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "@typebot.io/js",
"version": "0.0.0",
"description": "",
"main": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {
"start:demo": "vite",
"dev:demo": "vite",
"dev": "rollup --watch --config rollup.config.mjs",
"build": "rollup --config rollup.config.mjs",
"lint": "eslint --fix \"src/**/*.ts*\""
},
"license": "MIT",
"dependencies": {
"@power-elements/stripe-elements": "^3.3.0",
"@stripe/stripe-js": "1.46.0",
"models": "workspace:*",
"phone": "^3.1.31",
"solid-element": "^1.6.3",
"solid-js": "^1.6.5",
"utils": "workspace:*"
},
"devDependencies": {
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-replace": "^5.0.1",
"@rollup/plugin-typescript": "^10.0.1",
"@types/react": "^18.0.26",
"autoprefixer": "10.4.13",
"babel-preset-solid": "^1.6.3",
"eslint": "8.29.0",
"eslint-config-custom": "workspace:*",
"eslint-plugin-solid": "^0.9.1",
"postcss": "8.4.20",
"react": "^18.2.0",
"rollup": "^3.7.4",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript-paths": "^1.4.0",
"tailwindcss": "3.2.4",
"tsconfig": "workspace:*",
"tsup": "6.5.0",
"typescript": "^4.9.4",
"vite": "^4.0.1",
"vite-plugin-solid": "^2.5.0"
}
}

View File

@ -0,0 +1,56 @@
import resolve from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
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 { typescriptPaths } from 'rollup-plugin-typescript-paths'
import dts from 'rollup-plugin-dts'
import typescript from '@rollup/plugin-typescript'
const extensions = ['.ts', '.tsx']
const webComponentsConfig = {
input: './src/index.ts',
output: {
file: 'dist/index.mjs',
format: 'es',
},
external: ['models', 'utils', 'react'],
plugins: [
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
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 config = [
webComponentsConfig,
{
input: './dist/dts/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'es' }],
plugins: [dts()],
},
]
export default config

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,225 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:host,
:root {
--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);
}

View File

@ -0,0 +1,133 @@
import { LiteBadge } from './LiteBadge'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import { getViewerUrl, isDefined, isNotEmpty } from 'utils'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import css from '../assets/index.css'
import { InitialChatReply, StartParams } from 'models'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext } from '@/types'
import { injectHeadCode } from '@/utils/injectHeadCode'
export type BotProps = StartParams & {
initialChatReply?: InitialChatReply
apiHost?: string
}
export const Bot = (props: BotProps) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>(props.initialChatReply)
onMount(() => {
if (!props.typebotId) return
const initialChatReplyValue = initialChatReply()
if (isDefined(initialChatReplyValue)) {
const customHeadCode =
initialChatReplyValue.typebot.settings.metadata.customHeadCode
if (customHeadCode) {
injectHeadCode(customHeadCode)
}
} else {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
getInitialChatReplyQuery({
typebotId: props.typebotId,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: props.resultId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
}).then((initialChatReply) => {
setInitialChatReply(initialChatReply)
})
}
})
return (
<Show
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
fallback={() => (
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
)}
>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
initialChatReply={initialChatReply}
context={{
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
typebotId: props.typebotId as string,
resultId: initialChatReply.resultId,
}}
/>
)}
</Show>
</Show>
)
}
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
}
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'
}:wght@300;400;600&display=swap')`
font.rel = 'stylesheet'
document.head.appendChild(font)
}
onMount(() => {
injectCustomFont()
if (botContainer) {
resizeObserver.observe(botContainer)
}
})
onCleanup(() => {
if (botContainer) {
resizeObserver.unobserve(botContainer)
}
})
return (
<>
<style>{css}</style>
<div
ref={botContainer}
class="flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
initialChatReply={props.initialChatReply}
/>
</div>
<Show
when={
props.initialChatReply.typebot.settings.general.isBrandingEnabled
}
>
<LiteBadge botContainer={botContainer} />
</Show>
</div>
</>
)
}

View File

@ -0,0 +1,49 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Avatar } from '@/components/avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal'
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 w-10 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 avatarSrc={props.hostAvatarSrc} />
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { BotContext } from '@/types'
import { ChatReply, Settings, Theme } from 'models'
import { createSignal, For, 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
onSubmit: (input: string) => void
onSkip: () => void
}
export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
const displayNextMessage = () => {
setDisplayedMessageIndex(
displayedMessageIndex() === props.messages.length
? displayedMessageIndex()
: displayedMessageIndex() + 1
)
}
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
? '50px'
: '0.5rem',
}}
>
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
{(message) => (
<HostBubble
message={message}
onTransitionEnd={displayNextMessage}
/>
)}
</For>
</div>
</div>
{props.input && displayedMessageIndex() === props.messages.length && (
<InputChatBlock
block={props.input}
inputIndex={props.inputIndex}
onSubmit={props.onSubmit}
onSkip={props.onSkip}
guestAvatar={props.theme.chat.guestAvatar}
context={props.context}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { ChatReply, InitialChatReply } from 'models'
import { createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext } from '@/types'
import { executeIntegrations } from '@/utils/executeIntegrations'
import { executeLogic } from '@/utils/executeLogic'
type Props = {
initialChatReply: InitialChatReply
context: BotContext
}
export const ConversationContainer = (props: Props) => {
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
},
])
const sendMessage = async (message: string) => {
const data = await sendMessageQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message,
})
if (!data) return
if (data.integrations) {
executeIntegrations(data.integrations)
}
if (data.logic) {
await executeLogic(data.logic)
}
setChatChunks((displayedChunks) => [
...displayedChunks,
{
input: data.input,
messages: data.messages,
},
])
}
return (
<div class="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view">
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk
inputIndex={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={props.initialChatReply.typebot.theme}
settings={props.initialChatReply.typebot.settings}
onSubmit={sendMessage}
onSkip={() => {
// TODO: implement skip
}}
context={props.context}
/>
)}
</For>
</div>
)
}

View File

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

View File

@ -0,0 +1,182 @@
import {
ChatReply,
ChoiceInputBlock,
DateInputOptions,
EmailInputBlock,
FileInputBlock,
InputBlockType,
NumberInputBlock,
PaymentInputOptions,
PhoneNumberInputBlock,
RatingInputBlock,
RuntimeOptions,
TextInputBlock,
Theme,
UrlInputBlock,
} from 'models'
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 'utils'
import { isMobile } from '@/utils/isMobileSignal'
import { PaymentForm } from '@/features/blocks/inputs/payment'
type Props = {
block: NonNullable<ChatReply['input']>
guestAvatar?: Theme['chat']['guestAvatar']
inputIndex: number
context: BotContext
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)
}
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.guestAvatar?.isEnabled && (
<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}
onSubmit={handleSubmit}
onSkip={() => props.onSkip()}
hasGuestAvatar={props.guestAvatar?.isEnabled ?? false}
/>
</div>
</Match>
</Switch>
)
}
const Input = (props: {
context: BotContext
block: NonNullable<ChatReply['input']>
inputIndex: number
hasGuestAvatar: boolean
onSubmit: (answer: InputSubmitContent) => void
onSkip: () => void
}) => {
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
return (
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
block={
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.DATE}>
<DateForm
options={props.block.options as DateInputOptions}
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 & { prefilledValue?: string }}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.FILE}>
<FileUploadForm
context={props.context}
block={props.block as FileInputBlock}
onSubmit={onSubmit}
// eslint-disable-next-line solid/reactivity
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={() =>
props.onSubmit({
value:
(props.block.options as PaymentInputOptions).labels.success ??
'Success',
})
}
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1,48 @@
import { onCleanup, onMount } from 'solid-js'
type Props = {
botContainer: HTMLDivElement | undefined
}
export const LiteBadge = (props: Props) => {
let liteBadge: HTMLAnchorElement | undefined
let observer: MutationObserver | undefined
onMount(() => {
if (!document || !props.botContainer) return
observer = new MutationObserver(function (mutations_list) {
mutations_list.forEach(function (mutation) {
mutation.removedNodes.forEach(function (removed_node) {
if (
'id' in removed_node &&
liteBadge &&
removed_node.id == 'lite-badge'
)
props.botContainer?.append(liteBadge)
})
})
})
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="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
style={{ bottom: '20px' }}
id="lite-badge"
>
Made with <span class="text-blue-500">Typebot</span>.
</a>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import { Show } from 'solid-js'
import { isDefined } from 'utils'
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 cursor-pointer"
data-testid="guest-bubble"
>
{props.message}
</span>
<Show when={isDefined(props.avatarSrc)}>
<Avatar avatarSrc={props.avatarSrc} />
</Show>
</div>
)

View File

@ -0,0 +1,61 @@
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 {
AudioBubbleContent,
BubbleBlockType,
ChatMessage,
EmbedBubbleContent,
ImageBubbleContent,
TextBubbleContent,
VideoBubbleContent,
} from 'models'
import { Match, Switch } from 'solid-js'
type Props = {
message: ChatMessage
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'>}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.IMAGE}>
<ImageBubble
url={(props.message.content as ImageBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.VIDEO}>
<VideoBubble
content={props.message.content as VideoBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.EMBED}>
<EmbedBubble
content={props.message.content as EmbedBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.AUDIO}>
<AudioBubble
url={(props.message.content as AudioBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
</Switch>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
import { isMobile } from '@/utils/isMobileSignal'
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={local.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { render } from 'solid-js/web'
import { App } from './App'
import '../assets/index.css'
render(() => <App />, document.getElementById('root') as HTMLElement)

View File

@ -0,0 +1,58 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { AudioBubbleContent } from 'models'
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 lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<audio
src={props.url}
class={
'z-10 text-fade-in m-2 ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{ height: isTyping() ? '2rem' : 'revert' }}
autoplay
controls
/>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { EmbedBubbleContent } from 'models'
import { createSignal, onMount } from 'solid-js'
type Props = {
content: EmbedBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const EmbedBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, 1000)
})
return (
<div class="flex flex-col w-full animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '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() ? '2rem' : `${props.content.height}px`,
}}
/>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,70 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { ImageBubbleContent } from 'models'
import { createSignal, onMount } from 'solid-js'
type Props = {
url: ImageBubbleContent['url']
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = (props: Props) => {
let image: HTMLImageElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!image) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, mediaLoadingFallbackTimeout)
image.onload = () => {
clearTimeout(timeout)
setIsTyping(false)
onTypingEnd()
}
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
>
{isTyping() ? <TypingBubble /> : null}
</div>
<figure class="p-4 z-10">
<img
ref={image}
src={props.url}
class={
'text-fade-in w-auto rounded-md max-w-full ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
'max-height': '32rem',
height: isTyping() ? '2rem' : 'auto',
}}
alt="Bubble image"
/>
</figure>
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,69 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { TextBubbleContent, TypingEmulation } from 'models'
import { createSignal, onMount } from 'solid-js'
import { computeTypingDuration } from '../utils/computeTypingDuration'
type Props = {
content: Pick<TextBubbleContent, 'html' | 'plainText'>
onTransitionEnd: () => void
typingEmulation?: TypingEmulation
}
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
export const TextBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!isTyping) return
const typingDuration = computeTypingDuration(
props.content.plainText,
props.typingEmulation ?? defaultTypingEmulation
)
setTimeout(() => {
onTypingEnd()
}, typingDuration)
})
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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
data-testid="host-bubble"
>
{isTyping() && <TypingBubble />}
</div>
<p
style={{
'text-overflow': 'ellipsis',
}}
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative ' +
(isTyping() ? 'opacity-0 h-6' : 'opacity-100 h-full')
}
innerHTML={props.content.html}
/>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,115 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { VideoBubbleContent, VideoBubbleContentType } from 'models'
import { createSignal, Match, onMount, Switch } from 'solid-js'
type Props = {
content: VideoBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
const showContentAfterMediaLoad = () => {
setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
}
onMount(() => {
if (!isTyping) return
showContentAfterMediaLoad()
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '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 &&
[
VideoBubbleContentType.VIMEO,
VideoBubbleContentType.YOUTUBE,
].includes(props.content.type)
}
>
<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 ? '2rem' : 'auto',
'max-height': window.navigator.vendor.match(/apple/i) ? '40vh' : '',
}}
autoplay
>
<source src={props.content.url} type="video/mp4" />
Sorry, your browser doesn&apos;t support embedded videos.
</video>
</Match>
<Match
when={
props.content?.type &&
[
VideoBubbleContentType.VIMEO,
VideoBubbleContentType.YOUTUBE,
].includes(props.content.type)
}
>
<iframe
src={`${
props.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
}/${props.content.id}`}
class={
'w-full p-4 text-fade-in z-10 rounded-md ' +
(props.isTyping ? 'opacity-0' : 'opacity-100')
}
height={props.isTyping ? '2rem' : '200px'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</Match>
</Switch>
)
}

View File

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

View File

@ -0,0 +1,83 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { ChoiceInputBlock } from 'models'
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) => (e: MouseEvent) => {
e.preventDefault()
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'
}
onClick={(event) => handleClick(index())(event)}
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-testid="button"
data-itemid={item.id}
>
{item.content}
</button>
{props.inputIndex === 0 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
</span>
)}
</span>
)}
</For>
</div>
<div class="flex">
{selectedIndices().length > 0 && (
<SendButton disableIcon>
{props.block.options?.buttonLabel ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}

View File

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

View File

@ -0,0 +1,100 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { DateInputOptions } from 'models'
import { createSignal } from 'solid-js'
import { parseReadableDate } from '../utils/parseReadableDate'
type Props = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
}
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal({ from: '', to: '' })
return (
<div class="flex flex-col w-full lg:w-4/6">
<div class="flex 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"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
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"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
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>
)
}

View File

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

View File

@ -0,0 +1,26 @@
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(from).toLocaleString(
currentLocale,
formatOptions
)
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@ -0,0 +1,65 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { EmailInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: EmailInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,258 @@
import { SendButton, Spinner } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { FileInputBlock } from 'models'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from 'utils'
type Props = {
context: BotContext
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: () => 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: `/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: `/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 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 '
}
onClick={() => props.onSkip()}
>
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'
}
onClick={clearFiles}
>
Clear
</button>
</Show>
<SendButton type="submit" disableIcon>
{props.block.options.labels.button
? `${props.block.options.labels.button} ${
selectedFiles().length
} file${selectedFiles().length > 1 ? 's' : ''}`
: 'Upload'}
</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"
>
<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
class="mb-3"
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"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)

View File

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

View File

@ -0,0 +1,68 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { NumberInputBlock } from 'models'
import { createSignal } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,23 @@
import { BotContext } from '@/types'
import { PaymentInputOptions, PaymentProvider, RuntimeOptions } from 'models'
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
// eslint-disable-next-line solid/reactivity
onSuccess={props.onSuccess}
options={props.options}
context={props.context}
/>
</Match>
</Switch>
)

View File

@ -0,0 +1,119 @@
import { SendButton } from '@/components/SendButton'
import { createEffect, createSignal, Show } from 'solid-js'
import { Stripe, StripeElements } from '@stripe/stripe-js'
import { BotContext } from '@/types'
import { PaymentInputOptions, RuntimeOptions } from 'models'
import '@power-elements/stripe-elements'
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'stripe-payment-request': unknown
}
}
}
// TODO: Implement support for payment input. (WIP)
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
let stripe: Stripe | undefined
let elements: StripeElements | undefined
let ignoreFirstPaymentIntentCall = true
export const StripePaymentForm = (props: Props) => {
const [message, setMessage] = createSignal<string>()
const [isLoading, setIsLoading] = createSignal(false)
createEffect(() => {
if (!stripe) return
if (ignoreFirstPaymentIntentCall)
return (ignoreFirstPaymentIntentCall = false)
stripe
.retrievePaymentIntent(props.options.paymentIntentSecret)
.then(({ paymentIntent }) => {
switch (paymentIntent?.status) {
case 'succeeded':
setMessage('Payment succeeded!')
break
case 'processing':
setMessage('Your payment is processing.')
break
case 'requires_payment_method':
setMessage('Your payment was not successful, please try again.')
break
default:
setMessage('Something went wrong.')
break
}
})
})
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"
>
{/* <stripe-payment-request
publishable-key={props.options.publicKey}
client-secret={props.options.paymentIntentSecret}
generate="source"
amount="125"
label="Double Double"
country="CA"
currency={props.options.currency}
/> */}
<SendButton
isLoading={isLoading() || !elements}
class="mt-4 w-full max-w-lg"
disableIcon
>
{props.options.labels.button} {props.options.amountLabel}
</SendButton>
<Show when={message()}>
<div
id="payment-message"
class="typebot-input-error-message mt-4 text-center"
>
{message()}
</div>
</Show>
</form>
)
}

View File

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

View File

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

View File

@ -0,0 +1,108 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { PhoneNumberInputBlock } from 'models'
import { createSignal, For } from 'solid-js'
import { phoneCountries } from 'utils/phoneCountries'
type PhoneInputProps = {
block: PhoneNumberInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const PhoneInput = (props: PhoneInputProps) => {
const [selectedCountryCode, setSelectedCountryCode] = createSignal('INT')
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string | undefined) => {
setInputValue(inputValue as string)
const matchedCountry = phoneCountries.find(
(country) =>
country.dial_code === inputValue &&
country.code !== selectedCountryCode()
)
if (matchedCountry) setSelectedCountryCode(matchedCountry.code)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
const selectNewCountryCode = (
event: Event & { currentTarget: { value: string } }
) => {
setSelectedCountryCode(event.currentTarget.value)
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': '400px',
}}
onKeyDown={submitWhenEnter}
>
<div class="flex flex-1">
<select
onChange={selectNewCountryCode}
class="w-12 pl-2 focus:outline-none"
>
<option selected>
{
phoneCountries.find(
(country) => selectedCountryCode() === country.code
)?.flag
}
</option>
<For
each={phoneCountries.filter(
(country) => country.code !== selectedCountryCode()
)}
>
{(country) => (
<option value={country.code}>
{country.name} ({country.dial_code})
</option>
)}
</For>
</select>
<ShortTextInput
type="tel"
ref={inputRef}
value={inputValue()}
onInput={handleInput}
placeholder={
props.block.options.labels.placeholder ?? 'Your phone number...'
}
autofocus={!isMobile()}
/>
</div>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,116 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { RatingInputBlock, RatingInputOptions } from 'models'
import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from 'utils'
type Props = {
block: RatingInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ? Number(props.block.prefilledValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
props.onSubmit({ value: rating.toString() })
}
const handleClick = (rating: number) => 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
onClick={(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 ' +
(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
}
onClick={() => props.onClick(props.idx)}
/>
</Match>
</Switch>
)
}
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`

View File

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

View File

@ -0,0 +1,75 @@
import { Textarea, ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { TextInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: TextInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,71 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { UrlInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: UrlInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { executeCode } from '@/features/blocks/logic/code'
import { CodeToExecute } from 'models'
export const executeChatwoot = (chatwoot: { codeToExecute: CodeToExecute }) => {
executeCode(chatwoot.codeToExecute)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
import { CodeToExecute } from 'models'
export const executeCode = async ({ content, args }: CodeToExecute) => {
const func = Function(...args.map((arg) => arg.id), content)
try {
await func(...args.map((arg) => arg.value))
} catch (err) {
console.error(err)
}
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { RedirectOptions } from 'models'
export const executeRedirect = ({ url, isNewTab }: RedirectOptions) => {
if (!url) return
window.open(url, isNewTab ? '_blank' : '_self')
}

View File

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

View File

@ -0,0 +1,63 @@
import styles from '../../../assets/index.css'
import { createSignal } from 'solid-js'
export const Bubble = () => {
const [isBotOpened, setIsBotOpened] = createSignal(false)
const toggleBot = () => {
setIsBotOpened(!isBotOpened())
}
return (
<>
<style>{styles}</style>
<button
onClick={toggleBot}
class="bg-blue-500 text-red-300 absolute bottom-4 right-4 w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center"
>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 stroke-white stroke-2 fill-transparent absolute ' +
(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>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 fill-white absolute ' +
(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>
<div
style={{
width: '400px',
height: 'calc(100% - 104px)',
'max-height': '704px',
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',
}}
class={
'absolute bottom-20 right-4 rounded-2xl ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
/>
</>
)
}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export const Popup = () => {
return <div />
}

View File

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

View File

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

1
packages/js/src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css'

2
packages/js/src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { registerWebComponents } from './register'
export type { BotProps } from './components/Bot'

View File

@ -0,0 +1,45 @@
import { GoogleAnalyticsOptions } from 'models'
declare const gtag: (
type: string,
action: string | undefined,
options: {
event_category: string | undefined
event_label: string | undefined
value: number | undefined
}
) => void
const initGoogleAnalytics = (id: string): Promise<void> =>
new Promise((resolve) => {
const existingScript = document.getElementById('gtag')
if (!existingScript) {
const script = document.createElement('script')
script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`
script.id = 'gtag'
const initScript = document.createElement('script')
initScript.innerHTML = `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${id}');
`
document.body.appendChild(script)
document.body.appendChild(initScript)
script.onload = () => {
resolve()
}
}
if (existingScript) resolve()
})
export const sendGaEvent = (options: GoogleAnalyticsOptions) => {
if (!options) return
gtag('event', options.action, {
event_category: options.category,
event_label: options.label,
value: options.value,
})
}
export default initGoogleAnalytics

View File

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

View File

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

View File

@ -0,0 +1,7 @@
/* eslint-disable solid/reactivity */
import { customElement } from 'solid-element'
import { Bot, BotProps } from './components/Bot'
export const registerWebComponents = (props: BotProps) => {
customElement('typebot-standard', props, Bot)
}

11
packages/js/src/types.ts Normal file
View File

@ -0,0 +1,11 @@
export type InputSubmitContent = {
label?: string
value: string
}
export type BotContext = {
typebotId: string
resultId: string
isPreview: boolean
apiHost?: string
}

View File

@ -0,0 +1,14 @@
import { executeChatwoot } from '@/features/blocks/integrations/chatwoot'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/utils/executeGoogleAnalytics'
import { ChatReply } from 'models'
export const executeIntegrations = async (
integrations: ChatReply['integrations']
) => {
if (integrations?.chatwoot?.codeToExecute) {
executeChatwoot(integrations.chatwoot)
}
if (integrations?.googleAnalytics) {
executeGoogleAnalyticsBlock(integrations.googleAnalytics)
}
}

View File

@ -0,0 +1,12 @@
import { executeCode } from '@/features/blocks/logic/code'
import { executeRedirect } from '@/features/blocks/logic/redirect'
import { ChatReply } from 'models'
export const executeLogic = async (logic: ChatReply['logic']) => {
if (logic?.codeToExecute) {
await executeCode(logic.codeToExecute)
}
if (logic?.redirect) {
executeRedirect(logic.redirect)
}
}

View File

@ -0,0 +1,3 @@
export const injectHeadCode = (headCode: string) => {
document.head.innerHTML = document.head.innerHTML + headCode
}

View File

@ -0,0 +1,3 @@
import { createSignal } from 'solid-js'
export const [isMobile, setIsMobile] = createSignal<boolean>()

View File

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
keyframes: {
'fade-in': {
'0%': {
opacity: '0',
},
'100%': {
opacity: '1',
},
},
},
animation: {
'fade-in': 'fade-in 0.3s ease-out',
},
},
},
plugins: [],
}

18
packages/js/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "tsconfig/base.json",
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"jsx": "preserve",
"jsxImportSource": "solid-js",
"module": "ESNext",
"target": "ESNext",
"declaration": true,
"declarationDir": "dts",
"emitDeclarationOnly": true
}
}

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import solidPlugin from 'vite-plugin-solid'
import { resolve } from 'path'
export default defineConfig({
plugins: [solidPlugin()],
server: {
port: 3005,
},
build: {
target: 'esnext',
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
optimizeDeps: {
include: ['models', 'utils'],
},
})