1
packages/js/.eslintignore
Normal file
1
packages/js/.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
/src/react/**
|
10
packages/js/.eslintrc.js
Normal file
10
packages/js/.eslintrc.js
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/js/.gitignore
vendored
Normal file
2
packages/js/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
16
packages/js/index.html
Normal file
16
packages/js/index.html
Normal 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
50
packages/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
56
packages/js/rollup.config.mjs
Normal file
56
packages/js/rollup.config.mjs
Normal 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
|
BIN
packages/js/src/assets/favicon.ico
Normal file
BIN
packages/js/src/assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
225
packages/js/src/assets/index.css
Normal file
225
packages/js/src/assets/index.css
Normal 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);
|
||||
}
|
133
packages/js/src/components/Bot.tsx
Normal file
133
packages/js/src/components/Bot.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './ConversationContainer'
|
182
packages/js/src/components/InputChatBlock.tsx
Normal file
182
packages/js/src/components/InputChatBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
48
packages/js/src/components/LiteBadge.tsx
Normal file
48
packages/js/src/components/LiteBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
59
packages/js/src/components/SendButton.tsx
Normal file
59
packages/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>
|
||||
)
|
24
packages/js/src/components/avatars/Avatar.tsx
Normal file
24
packages/js/src/components/avatars/Avatar.tsx
Normal 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>
|
||||
)
|
54
packages/js/src/components/avatars/DefaultAvatar.tsx
Normal file
54
packages/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>
|
||||
)
|
||||
}
|
26
packages/js/src/components/bubbles/GuestBubble.tsx
Normal file
26
packages/js/src/components/bubbles/GuestBubble.tsx
Normal 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>
|
||||
)
|
61
packages/js/src/components/bubbles/HostBubble.tsx
Normal file
61
packages/js/src/components/bubbles/HostBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
7
packages/js/src/components/bubbles/TypingBubble.tsx
Normal file
7
packages/js/src/components/bubbles/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>
|
||||
)
|
13
packages/js/src/components/icons/SendIcon.tsx
Normal file
13
packages/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>
|
||||
)
|
1
packages/js/src/components/icons/index.ts
Normal file
1
packages/js/src/components/icons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SendIcon'
|
23
packages/js/src/components/inputs/ShortTextInput.tsx
Normal file
23
packages/js/src/components/inputs/ShortTextInput.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
26
packages/js/src/components/inputs/Textarea.tsx
Normal file
26
packages/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/js/src/components/inputs/index.ts
Normal file
2
packages/js/src/components/inputs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ShortTextInput'
|
||||
export * from './Textarea'
|
11
packages/js/src/demo/App.tsx
Normal file
11
packages/js/src/demo/App.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
5
packages/js/src/demo/index.tsx
Normal file
5
packages/js/src/demo/index.tsx
Normal 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)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './AudioBubble'
|
1
packages/js/src/features/blocks/bubbles/audio/index.ts
Normal file
1
packages/js/src/features/blocks/bubbles/audio/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './EmbedBubble'
|
1
packages/js/src/features/blocks/bubbles/embed/index.ts
Normal file
1
packages/js/src/features/blocks/bubbles/embed/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/bubbles/image/index.ts
Normal file
1
packages/js/src/features/blocks/bubbles/image/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ImageBubble } from './components/ImageBubble'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextBubble } from './components/TextBubble'
|
@ -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
|
||||
}
|
@ -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'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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/bubbles/video/index.ts
Normal file
1
packages/js/src/features/blocks/bubbles/video/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { VideoBubble } from './components/VideoBubble'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/buttons/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/buttons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ChoiceForm } from './components/ChoiceForm'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/date/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/date/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DateForm } from './components/DateForm'
|
@ -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}` : ''}`
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/email/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/email/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { EmailInput } from './components/EmailInput'
|
@ -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>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { FileUploadForm } from './components/FileUploadForm'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/number/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/number/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { NumberInput } from './components/NumberInput'
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './PaymentForm'
|
1
packages/js/src/features/blocks/inputs/payment/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/payment/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/phone/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/phone/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PhoneInput } from './components/PhoneInput'
|
@ -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>`
|
1
packages/js/src/features/blocks/inputs/rating/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/rating/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { RatingForm } from './components/RatingForm'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextInput } from './components/TextInput'
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/js/src/features/blocks/inputs/url/index.ts
Normal file
1
packages/js/src/features/blocks/inputs/url/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { UrlInput } from './components/UrlInput'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1,6 @@
|
||||
import { executeCode } from '@/features/blocks/logic/code'
|
||||
import { CodeToExecute } from 'models'
|
||||
|
||||
export const executeChatwoot = (chatwoot: { codeToExecute: CodeToExecute }) => {
|
||||
executeCode(chatwoot.codeToExecute)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeChatwoot'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeGoogleAnalytics'
|
1
packages/js/src/features/blocks/logic/code/index.ts
Normal file
1
packages/js/src/features/blocks/logic/code/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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)
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeCode'
|
1
packages/js/src/features/blocks/logic/redirect/index.ts
Normal file
1
packages/js/src/features/blocks/logic/redirect/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1,6 @@
|
||||
import { RedirectOptions } from 'models'
|
||||
|
||||
export const executeRedirect = ({ url, isNewTab }: RedirectOptions) => {
|
||||
if (!url) return
|
||||
window.open(url, isNewTab ? '_blank' : '_self')
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeRedirect'
|
63
packages/js/src/features/bubble/components/Bubble.tsx
Normal file
63
packages/js/src/features/bubble/components/Bubble.tsx
Normal 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')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
1
packages/js/src/features/bubble/components/index.ts
Normal file
1
packages/js/src/features/bubble/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Bubble'
|
1
packages/js/src/features/bubble/index.ts
Normal file
1
packages/js/src/features/bubble/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
3
packages/js/src/features/popup/components/Popup.tsx
Normal file
3
packages/js/src/features/popup/components/Popup.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const Popup = () => {
|
||||
return <div />
|
||||
}
|
1
packages/js/src/features/popup/components/index.ts
Normal file
1
packages/js/src/features/popup/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Popup'
|
1
packages/js/src/features/popup/index.ts
Normal file
1
packages/js/src/features/popup/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
1
packages/js/src/global.d.ts
vendored
Normal file
1
packages/js/src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module '*.css'
|
2
packages/js/src/index.ts
Normal file
2
packages/js/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { registerWebComponents } from './register'
|
||||
export type { BotProps } from './components/Bot'
|
45
packages/js/src/lib/gtag.ts
Normal file
45
packages/js/src/lib/gtag.ts
Normal 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
|
28
packages/js/src/queries/getInitialChatReplyQuery.ts
Normal file
28
packages/js/src/queries/getInitialChatReplyQuery.ts
Normal 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
|
||||
}
|
15
packages/js/src/queries/sendMessageQuery.ts
Normal file
15
packages/js/src/queries/sendMessageQuery.ts
Normal 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
|
||||
}
|
7
packages/js/src/register.tsx
Normal file
7
packages/js/src/register.tsx
Normal 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
11
packages/js/src/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type InputSubmitContent = {
|
||||
label?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type BotContext = {
|
||||
typebotId: string
|
||||
resultId: string
|
||||
isPreview: boolean
|
||||
apiHost?: string
|
||||
}
|
14
packages/js/src/utils/executeIntegrations.ts
Normal file
14
packages/js/src/utils/executeIntegrations.ts
Normal 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)
|
||||
}
|
||||
}
|
12
packages/js/src/utils/executeLogic.ts
Normal file
12
packages/js/src/utils/executeLogic.ts
Normal 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)
|
||||
}
|
||||
}
|
3
packages/js/src/utils/injectHeadCode.ts
Normal file
3
packages/js/src/utils/injectHeadCode.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const injectHeadCode = (headCode: string) => {
|
||||
document.head.innerHTML = document.head.innerHTML + headCode
|
||||
}
|
3
packages/js/src/utils/isMobileSignal.ts
Normal file
3
packages/js/src/utils/isMobileSignal.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createSignal } from 'solid-js'
|
||||
|
||||
export const [isMobile, setIsMobile] = createSignal<boolean>()
|
22
packages/js/tailwind.config.js
Normal file
22
packages/js/tailwind.config.js
Normal 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
18
packages/js/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
21
packages/js/vite.config.ts
Normal file
21
packages/js/vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user