♻️ Re-organize workspace folders
This commit is contained in:
@ -0,0 +1,58 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import type { AudioBubbleContent } from '@typebot.io/schemas'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
url: AudioBubbleContent['url']
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
const showAnimationDuration = 400
|
||||
const typingDuration = 500
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const AudioBubble = (props: Props) => {
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
|
||||
onMount(() => {
|
||||
typingTimeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
props.onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, typingDuration)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping() && <TypingBubble />}
|
||||
</div>
|
||||
<audio
|
||||
src={props.url}
|
||||
class={
|
||||
'z-10 text-fade-in m-2 ' +
|
||||
(isTyping() ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{ height: isTyping() ? '32px' : 'revert' }}
|
||||
autoplay
|
||||
controls
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './AudioBubble'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -0,0 +1,60 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import type { EmbedBubbleContent } from '@typebot.io/schemas'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
content: EmbedBubbleContent
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const EmbedBubble = (props: Props) => {
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
|
||||
onMount(() => {
|
||||
typingTimeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
props.onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col w-full animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div
|
||||
class={'flex relative z-10 items-start typebot-host-bubble w-full'}
|
||||
>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping() && <TypingBubble />}
|
||||
</div>
|
||||
<iframe
|
||||
id="embed-bubble-content"
|
||||
src={props.content.url}
|
||||
class={
|
||||
'w-full z-20 p-4 text-fade-in rounded-2xl ' +
|
||||
(isTyping() ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
height: isTyping() ? '32px' : `${props.content.height}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './EmbedBubble'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -0,0 +1,73 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import type { ImageBubbleContent } from '@typebot.io/schemas'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
url: ImageBubbleContent['url']
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const ImageBubble = (props: Props) => {
|
||||
let image: HTMLImageElement | undefined
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
|
||||
const onTypingEnd = () => {
|
||||
if (!isTyping()) return
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
props.onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!image) return
|
||||
typingTimeout = setTimeout(onTypingEnd, mediaLoadingFallbackTimeout)
|
||||
image.onload = () => {
|
||||
clearTimeout(typingTimeout)
|
||||
onTypingEnd()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping() ? <TypingBubble /> : null}
|
||||
</div>
|
||||
<figure class="p-4 z-10">
|
||||
<img
|
||||
ref={image}
|
||||
src={props.url}
|
||||
class={
|
||||
'text-fade-in w-full rounded-md ' +
|
||||
(isTyping() ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
'max-height': '512px',
|
||||
height: isTyping() ? '32px' : 'auto',
|
||||
}}
|
||||
alt="Bubble image"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ImageBubble } from './components/ImageBubble'
|
@ -0,0 +1,74 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { computeTypingDuration } from '../utils/computeTypingDuration'
|
||||
|
||||
type Props = {
|
||||
content: Pick<TextBubbleContent, 'html' | 'plainText'>
|
||||
typingEmulation: TypingEmulation
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
const defaultTypingEmulation = {
|
||||
enabled: true,
|
||||
speed: 300,
|
||||
maxDelay: 1.5,
|
||||
}
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const TextBubble = (props: Props) => {
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
|
||||
const onTypingEnd = () => {
|
||||
if (!isTyping()) return
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
props.onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isTyping) return
|
||||
const typingDuration =
|
||||
props.typingEmulation?.enabled === false
|
||||
? 0
|
||||
: computeTypingDuration(
|
||||
props.content.plainText,
|
||||
props.typingEmulation ?? defaultTypingEmulation
|
||||
)
|
||||
typingTimeout = setTimeout(onTypingEnd, typingDuration)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div class={'flex relative items-start typebot-host-bubble'}>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
data-testid="host-bubble"
|
||||
>
|
||||
{isTyping() && <TypingBubble />}
|
||||
</div>
|
||||
<p
|
||||
class={
|
||||
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis ' +
|
||||
(isTyping() ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
||||
}
|
||||
innerHTML={props.content.html}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextBubble } from './components/TextBubble'
|
@ -0,0 +1,16 @@
|
||||
import type { TypingEmulation } from '@typebot.io/schemas'
|
||||
|
||||
export const computeTypingDuration = (
|
||||
bubbleContent: string,
|
||||
typingSettings: TypingEmulation
|
||||
) => {
|
||||
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
|
||||
if (wordCount === 0) wordCount = bubbleContent.length
|
||||
const typedWordsPerMinute = typingSettings.speed
|
||||
let typingTimeout = typingSettings.enabled
|
||||
? (wordCount / typedWordsPerMinute) * 60000
|
||||
: 0
|
||||
if (typingTimeout > typingSettings.maxDelay * 1000)
|
||||
typingTimeout = typingSettings.maxDelay * 1000
|
||||
return typingTimeout
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import type { VideoBubbleContent } from '@typebot.io/schemas'
|
||||
import { VideoBubbleContentType } from '@typebot.io/schemas/features/blocks/bubbles/video/enums'
|
||||
import { createSignal, Match, onCleanup, onMount, Switch } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
content: VideoBubbleContent
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const VideoBubble = (props: Props) => {
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
|
||||
const onTypingEnd = () => {
|
||||
if (!isTyping()) return
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
props.onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
typingTimeout = setTimeout(onTypingEnd, 2000)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping() && <TypingBubble />}
|
||||
</div>
|
||||
<VideoContent content={props.content} isTyping={isTyping()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type VideoContentProps = {
|
||||
content: VideoBubbleContent
|
||||
isTyping: boolean
|
||||
}
|
||||
|
||||
const VideoContent = (props: VideoContentProps) => {
|
||||
return (
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
props.content?.type &&
|
||||
props.content.type === VideoBubbleContentType.URL
|
||||
}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
class={
|
||||
'p-4 focus:outline-none w-full z-10 text-fade-in rounded-md ' +
|
||||
(props.isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
height: props.isTyping ? '32px' : 'auto',
|
||||
'max-height': window.navigator.vendor.match(/apple/i) ? '40vh' : '',
|
||||
}}
|
||||
autoplay
|
||||
>
|
||||
<source src={props.content.url} type="video/mp4" />
|
||||
Sorry, your browser doesn't support embedded videos.
|
||||
</video>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
props.content?.type &&
|
||||
[
|
||||
VideoBubbleContentType.VIMEO,
|
||||
VideoBubbleContentType.YOUTUBE,
|
||||
].includes(props.content.type)
|
||||
}
|
||||
>
|
||||
<iframe
|
||||
src={`${
|
||||
props.content.type === VideoBubbleContentType.VIMEO
|
||||
? 'https://player.vimeo.com/video'
|
||||
: 'https://www.youtube.com/embed'
|
||||
}/${props.content.id}`}
|
||||
class={
|
||||
'w-full p-4 text-fade-in z-10 rounded-md ' +
|
||||
(props.isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
height={props.isTyping ? '32px' : '200px'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { VideoBubble } from './components/VideoBubble'
|
@ -0,0 +1,82 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import type { ChoiceInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, For } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
inputIndex: number
|
||||
block: ChoiceInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const ChoiceForm = (props: Props) => {
|
||||
const [selectedIndices, setSelectedIndices] = createSignal<number[]>([])
|
||||
|
||||
const handleClick = (itemIndex: number) => {
|
||||
if (props.block.options?.isMultipleChoice)
|
||||
toggleSelectedItemIndex(itemIndex)
|
||||
else props.onSubmit({ value: props.block.items[itemIndex].content ?? '' })
|
||||
}
|
||||
|
||||
const toggleSelectedItemIndex = (itemIndex: number) => {
|
||||
const existingIndex = selectedIndices().indexOf(itemIndex)
|
||||
if (existingIndex !== -1) {
|
||||
setSelectedIndices((selectedIndices) =>
|
||||
selectedIndices.filter((index) => index !== itemIndex)
|
||||
)
|
||||
} else {
|
||||
setSelectedIndices((selectedIndices) => [...selectedIndices, itemIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () =>
|
||||
props.onSubmit({
|
||||
value: selectedIndices()
|
||||
.map((itemIndex) => props.block.items[itemIndex].content)
|
||||
.join(', '),
|
||||
})
|
||||
|
||||
return (
|
||||
<form class="flex flex-col items-end" onSubmit={handleSubmit}>
|
||||
<div class="flex flex-wrap justify-end">
|
||||
<For each={props.block.items}>
|
||||
{(item, index) => (
|
||||
<span class="relative inline-flex ml-2 mb-2">
|
||||
<button
|
||||
role={
|
||||
props.block.options?.isMultipleChoice ? 'checkbox' : 'button'
|
||||
}
|
||||
type="button"
|
||||
on:click={() => handleClick(index())}
|
||||
class={
|
||||
'py-2 px-4 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
|
||||
(selectedIndices().some(
|
||||
(selectedIndex) => selectedIndex === index()
|
||||
) || !props.block.options?.isMultipleChoice
|
||||
? ''
|
||||
: 'selectable')
|
||||
}
|
||||
data-itemid={item.id}
|
||||
>
|
||||
{item.content}
|
||||
</button>
|
||||
{props.inputIndex === 0 && props.block.items.length === 1 && (
|
||||
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex">
|
||||
{selectedIndices().length > 0 && (
|
||||
<SendButton disableIcon>
|
||||
{props.block.options?.buttonLabel ?? 'Send'}
|
||||
</SendButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ChoiceForm } from './components/ChoiceForm'
|
@ -0,0 +1,112 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import type { DateInputOptions } from '@typebot.io/schemas'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { parseReadableDate } from '../utils/parseReadableDate'
|
||||
|
||||
type Props = {
|
||||
onSubmit: (inputValue: InputSubmitContent) => void
|
||||
options?: DateInputOptions
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
export const DateForm = (props: Props) => {
|
||||
const [inputValues, setInputValues] = createSignal(
|
||||
parseDefaultValue(props.defaultValue ?? '')
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center">
|
||||
<form
|
||||
class={'flex justify-between rounded-lg typebot-input pr-2 items-end'}
|
||||
onSubmit={(e) => {
|
||||
if (inputValues().from === '' && inputValues().to === '') return
|
||||
e.preventDefault()
|
||||
props.onSubmit({
|
||||
value: `${inputValues().from}${
|
||||
props.options?.isRange ? ` to ${inputValues().to}` : ''
|
||||
}`,
|
||||
label: parseReadableDate({
|
||||
...inputValues(),
|
||||
hasTime: props.options?.hasTime,
|
||||
isRange: props.options?.isRange,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class={
|
||||
'flex items-center p-4 ' +
|
||||
(props.options?.isRange ? 'pb-0' : '')
|
||||
}
|
||||
>
|
||||
{props.options?.isRange && (
|
||||
<p class="font-semibold mr-2">
|
||||
{props.options.labels?.from ?? 'From:'}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
|
||||
style={{
|
||||
'min-height': '32px',
|
||||
'min-width': '100px',
|
||||
'font-size': '16px',
|
||||
}}
|
||||
value={inputValues().from}
|
||||
type={props.options?.hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({
|
||||
...inputValues(),
|
||||
from: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
data-testid="from-date"
|
||||
/>
|
||||
</div>
|
||||
{props.options?.isRange && (
|
||||
<div class="flex items-center p-4">
|
||||
{props.options.isRange && (
|
||||
<p class="font-semibold">
|
||||
{props.options.labels?.to ?? 'To:'}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
|
||||
style={{
|
||||
'min-height': '32px',
|
||||
'min-width': '100px',
|
||||
'font-size': '16px',
|
||||
}}
|
||||
value={inputValues().to}
|
||||
type={props.options.hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({
|
||||
...inputValues(),
|
||||
to: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
data-testid="to-date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SendButton
|
||||
isDisabled={inputValues().to === '' && inputValues().from === ''}
|
||||
class="my-2 ml-2"
|
||||
>
|
||||
{props.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const parseDefaultValue = (defaultValue: string) => {
|
||||
if (!defaultValue.includes('to')) return { from: defaultValue, to: '' }
|
||||
const [from, to] = defaultValue.split(' to ')
|
||||
return { from, to }
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { DateForm } from './components/DateForm'
|
@ -0,0 +1,27 @@
|
||||
export const parseReadableDate = ({
|
||||
from,
|
||||
to,
|
||||
hasTime,
|
||||
isRange,
|
||||
}: {
|
||||
from: string
|
||||
to: string
|
||||
hasTime?: boolean
|
||||
isRange?: boolean
|
||||
}) => {
|
||||
const currentLocale = window.navigator.language
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: hasTime ? '2-digit' : undefined,
|
||||
minute: hasTime ? '2-digit' : undefined,
|
||||
}
|
||||
const fromReadable = new Date(
|
||||
hasTime ? from : from.replace(/-/g, '/')
|
||||
).toLocaleString(currentLocale, formatOptions)
|
||||
const toReadable = new Date(
|
||||
hasTime ? to : to.replace(/-/g, '/')
|
||||
).toLocaleString(currentLocale, formatOptions)
|
||||
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
import { ShortTextInput } from '@/components'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { EmailInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
block: EmailInputBlock
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const EmailInput = (props: Props) => {
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue() !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isMobile() && inputRef) inputRef.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ?? 'Type your email...'
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
isDisabled={inputValue() === ''}
|
||||
class="my-2 ml-2"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.block.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { EmailInput } from './components/EmailInput'
|
@ -0,0 +1,272 @@
|
||||
import { SendButton, Spinner } from '@/components/SendButton'
|
||||
import { BotContext, InputSubmitContent } from '@/types'
|
||||
import { guessApiHost } from '@/utils/guessApiHost'
|
||||
import { FileInputBlock } from '@typebot.io/schemas'
|
||||
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file'
|
||||
import { createSignal, Match, Show, Switch } from 'solid-js'
|
||||
import { uploadFiles } from '@typebot.io/lib'
|
||||
|
||||
type Props = {
|
||||
context: BotContext
|
||||
block: FileInputBlock
|
||||
onSubmit: (url: InputSubmitContent) => void
|
||||
onSkip: (label: string) => void
|
||||
}
|
||||
|
||||
export const FileUploadForm = (props: Props) => {
|
||||
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
|
||||
const [isUploading, setIsUploading] = createSignal(false)
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
|
||||
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
|
||||
const [errorMessage, setErrorMessage] = createSignal<string>()
|
||||
|
||||
const onNewFiles = (files: FileList) => {
|
||||
setErrorMessage(undefined)
|
||||
const newFiles = Array.from(files)
|
||||
if (
|
||||
newFiles.some(
|
||||
(file) =>
|
||||
file.size > (props.block.options.sizeLimit ?? 10) * 1024 * 1024
|
||||
)
|
||||
)
|
||||
return setErrorMessage(
|
||||
`A file is larger than ${props.block.options.sizeLimit ?? 10}MB`
|
||||
)
|
||||
if (!props.block.options.isMultipleAllowed && files)
|
||||
return startSingleFileUpload(newFiles[0])
|
||||
setSelectedFiles([...selectedFiles(), ...newFiles])
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedFiles().length === 0) return
|
||||
startFilesUpload(selectedFiles())
|
||||
}
|
||||
|
||||
const startSingleFileUpload = async (file: File) => {
|
||||
if (props.context.isPreview)
|
||||
return props.onSubmit({
|
||||
label: `File uploaded`,
|
||||
value: 'http://fake-upload-url.com',
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
|
||||
props.context.typebotId
|
||||
}/blocks/${props.block.id}`,
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
setIsUploading(false)
|
||||
if (urls.length)
|
||||
return props.onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
|
||||
setErrorMessage('An error occured while uploading the file')
|
||||
}
|
||||
const startFilesUpload = async (files: File[]) => {
|
||||
if (props.context.isPreview)
|
||||
return props.onSubmit({
|
||||
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
|
||||
value: files
|
||||
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
|
||||
.join(', '),
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
|
||||
props.context.typebotId
|
||||
}/blocks/${props.block.id}`,
|
||||
files: files.map((file) => ({
|
||||
file: file,
|
||||
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
|
||||
})),
|
||||
onUploadProgress: setUploadProgressPercent,
|
||||
})
|
||||
setIsUploading(false)
|
||||
setUploadProgressPercent(0)
|
||||
if (urls.length !== files.length)
|
||||
return setErrorMessage('An error occured while uploading the files')
|
||||
props.onSubmit({
|
||||
label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`,
|
||||
value: urls.join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDraggingOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => setIsDraggingOver(false)
|
||||
|
||||
const handleDropFile = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!e.dataTransfer?.files) return
|
||||
onNewFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const clearFiles = () => setSelectedFiles([])
|
||||
|
||||
return (
|
||||
<form class="flex flex-col w-full" onSubmit={handleSubmit}>
|
||||
<label
|
||||
for="dropzone-file"
|
||||
class={
|
||||
'typebot-upload-input py-6 flex flex-col justify-center items-center w-full bg-gray-50 rounded-lg border-2 border-gray-300 border-dashed cursor-pointer hover:bg-gray-100 px-8 mb-2 ' +
|
||||
(isDraggingOver() ? 'dragging-over' : '')
|
||||
}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDropFile}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={isUploading()}>
|
||||
<Show when={selectedFiles().length > 1} fallback={<Spinner />}>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
class="upload-progress-bar h-2.5 rounded-full"
|
||||
style={{
|
||||
width: `${
|
||||
uploadProgressPercent() > 0 ? uploadProgressPercent : 10
|
||||
}%`,
|
||||
transition: 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!isUploading()}>
|
||||
<>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
|
||||
<span class="relative">
|
||||
<FileIcon />
|
||||
<div
|
||||
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4"
|
||||
style={{ bottom: '5px' }}
|
||||
>
|
||||
{selectedFiles().length}
|
||||
</div>
|
||||
</span>
|
||||
</Show>
|
||||
<p
|
||||
class="text-sm text-gray-500 text-center"
|
||||
innerHTML={props.block.options.labels.placeholder}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple={props.block.options.isMultipleAllowed}
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</Match>
|
||||
</Switch>
|
||||
</label>
|
||||
<Show
|
||||
when={
|
||||
selectedFiles().length === 0 &&
|
||||
props.block.options.isRequired === false
|
||||
}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class={
|
||||
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button '
|
||||
}
|
||||
on:click={() =>
|
||||
props.onSkip(
|
||||
props.block.options.labels.skip ??
|
||||
defaultFileInputOptions.labels.skip
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.block.options.labels.skip ??
|
||||
defaultFileInputOptions.labels.skip}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.block.options.isMultipleAllowed &&
|
||||
selectedFiles().length > 0 &&
|
||||
!isUploading()
|
||||
}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex">
|
||||
<Show when={selectedFiles().length}>
|
||||
<button
|
||||
class={
|
||||
'secondary-button py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 mr-2'
|
||||
}
|
||||
on:click={clearFiles}
|
||||
>
|
||||
{props.block.options.labels.clear ??
|
||||
defaultFileInputOptions.labels.clear}
|
||||
</button>
|
||||
</Show>
|
||||
<SendButton type="submit" disableIcon>
|
||||
{props.block.options.labels.button ===
|
||||
defaultFileInputOptions.labels.button
|
||||
? `Upload ${selectedFiles().length} file${
|
||||
selectedFiles().length > 1 ? 's' : ''
|
||||
}`
|
||||
: props.block.options.labels.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={errorMessage()}>
|
||||
<p class="text-red-500 text-sm">{errorMessage()}</p>
|
||||
</Show>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const UploadIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-3 text-gray-500"
|
||||
>
|
||||
<polyline points="16 16 12 12 8 16" />
|
||||
<line x1="12" y1="12" x2="12" y2="21" />
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
|
||||
<polyline points="16 16 12 12 8 16" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FileIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-3 text-gray-500"
|
||||
>
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||
<polyline points="13 2 13 9 20 9" />
|
||||
</svg>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { FileUploadForm } from './components/FileUploadForm'
|
@ -0,0 +1,69 @@
|
||||
import { ShortTextInput } from '@/components'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { NumberInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
|
||||
type NumberInputProps = {
|
||||
block: NumberInputBlock
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const NumberInput = (props: NumberInputProps) => {
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue() !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isMobile() && inputRef) inputRef.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="number"
|
||||
style={{ appearance: 'auto' }}
|
||||
min={props.block.options?.min}
|
||||
max={props.block.options?.max}
|
||||
step={props.block.options?.step ?? 'any'}
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
isDisabled={inputValue() === ''}
|
||||
class="my-2 ml-2"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.block.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { NumberInput } from './components/NumberInput'
|
@ -0,0 +1,23 @@
|
||||
import { BotContext } from '@/types'
|
||||
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
|
||||
import { PaymentProvider } from '@typebot.io/schemas/features/blocks/inputs/payment/enums'
|
||||
import { Match, Switch } from 'solid-js'
|
||||
import { StripePaymentForm } from './StripePaymentForm'
|
||||
|
||||
type Props = {
|
||||
context: BotContext
|
||||
options: PaymentInputOptions & RuntimeOptions
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export const PaymentForm = (props: Props) => (
|
||||
<Switch>
|
||||
<Match when={props.options.provider === PaymentProvider.STRIPE}>
|
||||
<StripePaymentForm
|
||||
onSuccess={props.onSuccess}
|
||||
options={props.options}
|
||||
context={props.context}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
@ -0,0 +1,112 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { createSignal, onMount, Show } from 'solid-js'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import { BotContext } from '@/types'
|
||||
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
|
||||
import { loadStripe } from '@/lib/stripe'
|
||||
|
||||
type Props = {
|
||||
context: BotContext
|
||||
options: PaymentInputOptions & RuntimeOptions
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const slotName = 'stripe-payment-form'
|
||||
|
||||
let paymentElementSlot: HTMLSlotElement
|
||||
let stripe: Stripe | null = null
|
||||
let elements: StripeElements | null = null
|
||||
|
||||
export const StripePaymentForm = (props: Props) => {
|
||||
const [message, setMessage] = createSignal<string>()
|
||||
const [isMounted, setIsMounted] = createSignal(false)
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
onMount(async () => {
|
||||
initShadowMountPoint(paymentElementSlot)
|
||||
stripe = await loadStripe(props.options.publicKey)
|
||||
if (!stripe) return
|
||||
elements = stripe.elements({
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: getComputedStyle(paymentElementSlot).getPropertyValue(
|
||||
'--typebot-button-bg-color'
|
||||
),
|
||||
},
|
||||
},
|
||||
clientSecret: props.options.paymentIntentSecret,
|
||||
})
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
})
|
||||
paymentElement.mount('#payment-element')
|
||||
setTimeout(() => setIsMounted(true), 1000)
|
||||
})
|
||||
|
||||
const handleSubmit = async (event: Event & { submitter: HTMLElement }) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const { error, paymentIntent } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
// TO-DO: Handle redirection correctly.
|
||||
return_url: props.context.apiHost,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: props.options.additionalInformation?.name,
|
||||
email: props.options.additionalInformation?.email,
|
||||
phone: props.options.additionalInformation?.phoneNumber,
|
||||
},
|
||||
},
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
if (error?.type === 'validation_error') return
|
||||
if (error?.type === 'card_error') return setMessage(error.message)
|
||||
if (!error && paymentIntent.status === 'succeeded') return props.onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="payment-form"
|
||||
onSubmit={handleSubmit}
|
||||
class="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
|
||||
>
|
||||
<slot name={slotName} ref={paymentElementSlot} />
|
||||
<Show when={isMounted()}>
|
||||
<SendButton
|
||||
isLoading={isLoading()}
|
||||
class="mt-4 w-full max-w-lg animate-fade-in"
|
||||
disableIcon
|
||||
>
|
||||
{props.options.labels.button} {props.options.amountLabel}
|
||||
</SendButton>
|
||||
</Show>
|
||||
|
||||
<Show when={message()}>
|
||||
<div class="typebot-input-error-message mt-4 text-center animate-fade-in">
|
||||
{message()}
|
||||
</div>
|
||||
</Show>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const initShadowMountPoint = (element: HTMLElement) => {
|
||||
const rootNode = element.getRootNode() as ShadowRoot
|
||||
const host = rootNode.host
|
||||
const slotPlaceholder = document.createElement('div')
|
||||
slotPlaceholder.style.width = '100%'
|
||||
slotPlaceholder.slot = slotName
|
||||
host.appendChild(slotPlaceholder)
|
||||
const paymentElementContainer = document.createElement('div')
|
||||
paymentElementContainer.id = 'payment-element'
|
||||
slotPlaceholder.appendChild(paymentElementContainer)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './PaymentForm'
|
@ -0,0 +1 @@
|
||||
export * from './components'
|
@ -0,0 +1,152 @@
|
||||
import { ShortTextInput } from '@/components'
|
||||
import { ChevronDownIcon } from '@/components/icons/ChevronDownIcon'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { PhoneNumberInputOptions } from '@typebot.io/schemas'
|
||||
import { createSignal, For, onMount } from 'solid-js'
|
||||
import { isEmpty } from '@typebot.io/lib'
|
||||
import { phoneCountries } from '@typebot.io/lib/phoneCountries'
|
||||
|
||||
type PhoneInputProps = Pick<
|
||||
PhoneNumberInputOptions,
|
||||
'labels' | 'defaultCountryCode'
|
||||
> & {
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const PhoneInput = (props: PhoneInputProps) => {
|
||||
const [selectedCountryCode, setSelectedCountryCode] = createSignal(
|
||||
isEmpty(props.defaultCountryCode) ? 'INT' : props.defaultCountryCode
|
||||
)
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string | undefined) => {
|
||||
setInputValue(inputValue as string)
|
||||
if (
|
||||
(inputValue === '' || inputValue === '+') &&
|
||||
selectedCountryCode() !== 'INT'
|
||||
)
|
||||
setSelectedCountryCode('INT')
|
||||
const matchedCountry =
|
||||
inputValue?.startsWith('+') &&
|
||||
inputValue.length > 2 &&
|
||||
phoneCountries.reduce<typeof phoneCountries[number] | null>(
|
||||
(matchedCountry, country) => {
|
||||
if (
|
||||
!country?.dial_code ||
|
||||
(matchedCountry !== null && !matchedCountry.dial_code)
|
||||
) {
|
||||
return matchedCountry
|
||||
}
|
||||
if (
|
||||
inputValue?.startsWith(country.dial_code) &&
|
||||
country.dial_code.length > (matchedCountry?.dial_code.length ?? 0)
|
||||
) {
|
||||
return country
|
||||
}
|
||||
return matchedCountry
|
||||
},
|
||||
null
|
||||
)
|
||||
if (matchedCountry) setSelectedCountryCode(matchedCountry.code)
|
||||
}
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue() !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
const selectedCountryDialCode = phoneCountries.find(
|
||||
(country) => country.code === selectedCountryCode()
|
||||
)?.dial_code
|
||||
if (checkIfInputIsValid())
|
||||
props.onSubmit({
|
||||
value: inputValue().startsWith('+')
|
||||
? inputValue()
|
||||
: `${selectedCountryDialCode ?? ''}${inputValue()}`,
|
||||
})
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
const selectNewCountryCode = (
|
||||
event: Event & { currentTarget: { value: string } }
|
||||
) => {
|
||||
const code = event.currentTarget.value
|
||||
setSelectedCountryCode(code)
|
||||
const dial_code = phoneCountries.find(
|
||||
(country) => country.code === code
|
||||
)?.dial_code
|
||||
if (inputValue() === '' && dial_code) setInputValue(dial_code)
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isMobile() && inputRef) inputRef.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={'flex items-end justify-between rounded-lg pr-2 typebot-input'}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '400px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="relative typebot-country-select flex justify-center items-center rounded-md">
|
||||
<div class="pl-2 pr-1 flex items-center gap-2">
|
||||
<span>
|
||||
{
|
||||
phoneCountries.find(
|
||||
(country) => selectedCountryCode() === country.code
|
||||
)?.flag
|
||||
}
|
||||
</span>
|
||||
<ChevronDownIcon class="w-3" />
|
||||
</div>
|
||||
|
||||
<select
|
||||
onChange={selectNewCountryCode}
|
||||
class="absolute top-0 left-0 w-full h-full cursor-pointer opacity-0"
|
||||
>
|
||||
<For each={phoneCountries}>
|
||||
{(country) => (
|
||||
<option
|
||||
value={country.code}
|
||||
selected={country.code === selectedCountryCode()}
|
||||
>
|
||||
{country.name}{' '}
|
||||
{country.dial_code ? `(${country.dial_code})` : ''}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ShortTextInput
|
||||
type="tel"
|
||||
ref={inputRef}
|
||||
value={inputValue()}
|
||||
onInput={handleInput}
|
||||
placeholder={props.labels.placeholder ?? 'Your phone number...'}
|
||||
autofocus={!isMobile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SendButton
|
||||
type="button"
|
||||
isDisabled={inputValue() === ''}
|
||||
class="my-2 ml-2"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { PhoneInput } from './components/PhoneInput'
|
@ -0,0 +1,122 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import type { RatingInputBlock, RatingInputOptions } from '@typebot.io/schemas'
|
||||
import { createSignal, For, Match, Switch } from 'solid-js'
|
||||
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
|
||||
|
||||
type Props = {
|
||||
block: RatingInputBlock
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const RatingForm = (props: Props) => {
|
||||
const [rating, setRating] = createSignal<number | undefined>(
|
||||
props.defaultValue ? Number(props.defaultValue) : undefined
|
||||
)
|
||||
|
||||
const handleSubmit = (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const selectedRating = rating()
|
||||
if (isNotDefined(selectedRating)) return
|
||||
props.onSubmit({ value: selectedRating.toString() })
|
||||
}
|
||||
|
||||
const handleClick = (rating: number) => {
|
||||
if (props.block.options.isOneClickSubmitEnabled)
|
||||
props.onSubmit({ value: rating.toString() })
|
||||
setRating(rating)
|
||||
}
|
||||
|
||||
return (
|
||||
<form class="flex flex-col" onSubmit={handleSubmit}>
|
||||
{props.block.options.labels.left && (
|
||||
<span class="text-sm w-full mb-2 rating-label">
|
||||
{props.block.options.labels.left}
|
||||
</span>
|
||||
)}
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<For
|
||||
each={Array.from(
|
||||
Array(
|
||||
props.block.options.length +
|
||||
(props.block.options.buttonType === 'Numbers' ? 1 : 0)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{(_, idx) => (
|
||||
<RatingButton
|
||||
{...props.block.options}
|
||||
rating={rating()}
|
||||
idx={
|
||||
idx() + (props.block.options.buttonType === 'Numbers' ? 0 : 1)
|
||||
}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{props.block.options.labels.right && (
|
||||
<span class="text-sm w-full text-right mb-2 pr-2 rating-label">
|
||||
{props.block.options.labels.right}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div class="flex justify-end mr-2">
|
||||
{isDefined(rating()) && (
|
||||
<SendButton disableIcon>
|
||||
{props.block.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
type RatingButtonProps = {
|
||||
rating?: number
|
||||
idx: number
|
||||
onClick: (rating: number) => void
|
||||
} & RatingInputOptions
|
||||
|
||||
const RatingButton = (props: RatingButtonProps) => {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.buttonType === 'Numbers'}>
|
||||
<button
|
||||
on:click={(e) => {
|
||||
e.preventDefault()
|
||||
props.onClick(props.idx)
|
||||
}}
|
||||
class={
|
||||
'py-2 px-4 mr-2 mb-2 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
|
||||
(props.isOneClickSubmitEnabled ||
|
||||
(isDefined(props.rating) && props.idx <= props.rating)
|
||||
? ''
|
||||
: 'selectable')
|
||||
}
|
||||
>
|
||||
{props.idx}
|
||||
</button>
|
||||
</Match>
|
||||
<Match when={props.buttonType !== 'Numbers'}>
|
||||
<div
|
||||
class={
|
||||
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
|
||||
(isDefined(props.rating) && props.idx <= props.rating
|
||||
? 'selected'
|
||||
: '')
|
||||
}
|
||||
innerHTML={
|
||||
props.customIcon.isEnabled && !isEmpty(props.customIcon.svg)
|
||||
? props.customIcon.svg
|
||||
: defaultIcon
|
||||
}
|
||||
on:click={() => props.onClick(props.idx)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`
|
@ -0,0 +1 @@
|
||||
export { RatingForm } from './components/RatingForm'
|
@ -0,0 +1,76 @@
|
||||
import { Textarea, ShortTextInput } from '@/components'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { TextInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
block: TextInputBlock
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const TextInput = (props: Props) => {
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue() !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
if (props.block.options.isLong) return
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isMobile() && inputRef) inputRef.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': props.block.options.isLong ? undefined : '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
{props.block.options.isLong ? (
|
||||
<Textarea
|
||||
ref={inputRef as HTMLTextAreaElement}
|
||||
onInput={handleInput}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
onInput={handleInput}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SendButton
|
||||
type="button"
|
||||
isDisabled={inputValue() === ''}
|
||||
class="my-2 ml-2"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.block.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextInput } from './components/TextInput'
|
@ -0,0 +1,72 @@
|
||||
import { ShortTextInput } from '@/components'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { UrlInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
block: UrlInputBlock
|
||||
defaultValue?: string
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const UrlInput = (props: Props) => {
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string) => {
|
||||
if (!inputValue.startsWith('https://'))
|
||||
return inputValue === 'https:/'
|
||||
? undefined
|
||||
: setInputValue(`https://${inputValue}`)
|
||||
setInputValue(inputValue)
|
||||
}
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue() !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isMobile() && inputRef) inputRef.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ?? 'Type your URL...'
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="url"
|
||||
autocomplete="url"
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
isDisabled={inputValue() === ''}
|
||||
class="my-2 ml-2"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.block.options?.labels?.button ?? 'Send'}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { UrlInput } from './components/UrlInput'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1,8 @@
|
||||
import { executeScript } from '@/features/blocks/logic/script/executeScript'
|
||||
import type { ScriptToExecute } from '@typebot.io/schemas'
|
||||
|
||||
export const executeChatwoot = (chatwoot: {
|
||||
scriptToExecute: ScriptToExecute
|
||||
}) => {
|
||||
executeScript(chatwoot.scriptToExecute)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeChatwoot'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1,11 @@
|
||||
import { sendGaEvent } from '@/lib/gtag'
|
||||
import type { GoogleAnalyticsOptions } from '@typebot.io/schemas'
|
||||
|
||||
export const executeGoogleAnalyticsBlock = async (
|
||||
options: GoogleAnalyticsOptions
|
||||
) => {
|
||||
if (!options?.trackingId) return
|
||||
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
|
||||
await initGoogleAnalytics(options.trackingId)
|
||||
sendGaEvent(options)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeGoogleAnalytics'
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1,13 @@
|
||||
import type { RedirectOptions } from '@typebot.io/schemas'
|
||||
|
||||
export const executeRedirect = ({
|
||||
url,
|
||||
isNewTab,
|
||||
}: RedirectOptions): { blockedPopupUrl: string } | undefined => {
|
||||
if (!url) return
|
||||
const updatedWindow = window.open(url, isNewTab ? '_blank' : '_self')
|
||||
if (!updatedWindow)
|
||||
return {
|
||||
blockedPopupUrl: url,
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeRedirect'
|
@ -0,0 +1,17 @@
|
||||
import type { ScriptToExecute } from '@typebot.io/schemas'
|
||||
|
||||
export const executeScript = async ({ content, args }: ScriptToExecute) => {
|
||||
const func = Function(...args.map((arg) => arg.id), parseContent(content))
|
||||
try {
|
||||
await func(...args.map((arg) => arg.value))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const parseContent = (content: string) => {
|
||||
const contentWithoutScriptTags = content
|
||||
.replace(/<script>/g, '')
|
||||
.replace(/<\/script>/g, '')
|
||||
return contentWithoutScriptTags
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
type Props = {
|
||||
secondsToWaitFor: number
|
||||
}
|
||||
|
||||
export const executeWait = async ({ secondsToWaitFor }: Props) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, secondsToWaitFor * 1000))
|
||||
}
|
160
packages/embeds/js/src/features/bubble/components/Bubble.tsx
Normal file
160
packages/embeds/js/src/features/bubble/components/Bubble.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import {
|
||||
createSignal,
|
||||
onMount,
|
||||
Show,
|
||||
splitProps,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from 'solid-js'
|
||||
import styles from '../../../assets/index.css'
|
||||
import { CommandData } from '../../commands'
|
||||
import { BubbleButton } from './BubbleButton'
|
||||
import { PreviewMessage, PreviewMessageProps } from './PreviewMessage'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { BubbleParams } from '../types'
|
||||
import { Bot, BotProps } from '../../../components/Bot'
|
||||
|
||||
export type BubbleProps = BotProps &
|
||||
BubbleParams & {
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
onPreviewMessageClick?: () => void
|
||||
}
|
||||
|
||||
export const Bubble = (props: BubbleProps) => {
|
||||
const [bubbleProps, botProps] = splitProps(props, [
|
||||
'onOpen',
|
||||
'onClose',
|
||||
'previewMessage',
|
||||
'onPreviewMessageClick',
|
||||
'theme',
|
||||
])
|
||||
const [prefilledVariables, setPrefilledVariables] = createSignal(
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
botProps.prefilledVariables
|
||||
)
|
||||
const [isPreviewMessageDisplayed, setIsPreviewMessageDisplayed] =
|
||||
createSignal(false)
|
||||
const [previewMessage, setPreviewMessage] = createSignal<
|
||||
Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
|
||||
>({
|
||||
message: bubbleProps.previewMessage?.message ?? '',
|
||||
avatarUrl: bubbleProps.previewMessage?.avatarUrl,
|
||||
})
|
||||
|
||||
const [isBotOpened, setIsBotOpened] = createSignal(false)
|
||||
const [isBotStarted, setIsBotStarted] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('message', processIncomingEvent)
|
||||
const autoShowDelay = bubbleProps.previewMessage?.autoShowDelay
|
||||
if (isDefined(autoShowDelay)) {
|
||||
setTimeout(() => {
|
||||
showMessage()
|
||||
}, autoShowDelay)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('message', processIncomingEvent)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.prefilledVariables) return
|
||||
setPrefilledVariables((existingPrefilledVariables) => ({
|
||||
...existingPrefilledVariables,
|
||||
...props.prefilledVariables,
|
||||
}))
|
||||
})
|
||||
|
||||
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
|
||||
const { data } = event
|
||||
if (!data.isFromTypebot) return
|
||||
if (data.command === 'open') openBot()
|
||||
if (data.command === 'close') closeBot()
|
||||
if (data.command === 'toggle') toggleBot()
|
||||
if (data.command === 'showPreviewMessage') showMessage(data.message)
|
||||
if (data.command === 'hidePreviewMessage') hideMessage()
|
||||
if (data.command === 'setPrefilledVariables')
|
||||
setPrefilledVariables((existingPrefilledVariables) => ({
|
||||
...existingPrefilledVariables,
|
||||
...data.variables,
|
||||
}))
|
||||
}
|
||||
|
||||
const openBot = () => {
|
||||
if (!isBotStarted()) setIsBotStarted(true)
|
||||
hideMessage()
|
||||
setIsBotOpened(true)
|
||||
if (isBotOpened()) bubbleProps.onOpen?.()
|
||||
}
|
||||
|
||||
const closeBot = () => {
|
||||
setIsBotOpened(false)
|
||||
if (isBotOpened()) bubbleProps.onClose?.()
|
||||
}
|
||||
|
||||
const toggleBot = () => {
|
||||
isBotOpened() ? closeBot() : openBot()
|
||||
}
|
||||
|
||||
const handlePreviewMessageClick = () => {
|
||||
bubbleProps.onPreviewMessageClick?.()
|
||||
openBot()
|
||||
}
|
||||
|
||||
const showMessage = (
|
||||
previewMessage?: Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
|
||||
) => {
|
||||
if (previewMessage) setPreviewMessage(previewMessage)
|
||||
if (isBotOpened()) return
|
||||
setIsPreviewMessageDisplayed(true)
|
||||
}
|
||||
|
||||
const hideMessage = () => {
|
||||
setIsPreviewMessageDisplayed(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{styles}</style>
|
||||
<Show when={isPreviewMessageDisplayed()}>
|
||||
<PreviewMessage
|
||||
{...previewMessage()}
|
||||
previewMessageTheme={bubbleProps.theme?.previewMessage}
|
||||
onClick={handlePreviewMessageClick}
|
||||
onCloseClick={hideMessage}
|
||||
/>
|
||||
</Show>
|
||||
<BubbleButton
|
||||
{...bubbleProps.theme?.button}
|
||||
toggleBot={toggleBot}
|
||||
isBotOpened={isBotOpened()}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100% - 80px)',
|
||||
transition:
|
||||
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
|
||||
'transform-origin': 'bottom right',
|
||||
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
|
||||
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
|
||||
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
|
||||
'z-index': 42424242,
|
||||
}}
|
||||
class={
|
||||
'fixed bottom-20 sm:right-4 rounded-lg w-full sm:w-[400px] max-h-[704px] ' +
|
||||
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
|
||||
}
|
||||
>
|
||||
<Show when={isBotStarted()}>
|
||||
<Bot
|
||||
{...botProps}
|
||||
prefilledVariables={prefilledVariables()}
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { ButtonTheme } from '../types'
|
||||
|
||||
type Props = ButtonTheme & {
|
||||
isBotOpened: boolean
|
||||
toggleBot: () => void
|
||||
}
|
||||
|
||||
const defaultButtonColor = '#0042DA'
|
||||
const defaultIconColor = 'white'
|
||||
|
||||
export const BubbleButton = (props: Props) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => props.toggleBot()}
|
||||
class={
|
||||
'fixed bottom-4 right-4 shadow-md w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center animate-fade-in'
|
||||
}
|
||||
style={{
|
||||
'background-color': props.backgroundColor ?? defaultButtonColor,
|
||||
'z-index': 42424242,
|
||||
}}
|
||||
>
|
||||
<Show when={isNotDefined(props.customIconSrc)} keyed>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
style={{
|
||||
stroke: props.iconColor ?? defaultIconColor,
|
||||
}}
|
||||
class={
|
||||
`w-7 stroke-2 fill-transparent absolute duration-200 transition ` +
|
||||
(props.isBotOpened ? 'scale-0 opacity-0' : 'scale-100 opacity-100')
|
||||
}
|
||||
>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={props.customIconSrc}>
|
||||
<img
|
||||
src={props.customIconSrc}
|
||||
class="w-7 h-7 rounded-full object-cover"
|
||||
alt="Bubble button icon"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
style={{ fill: props.iconColor ?? 'white' }}
|
||||
class={
|
||||
`w-7 absolute duration-200 transition ` +
|
||||
(props.isBotOpened
|
||||
? 'scale-100 rotate-0 opacity-100'
|
||||
: 'scale-0 -rotate-180 opacity-0')
|
||||
}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.601 8.39897C18.269 8.06702 17.7309 8.06702 17.3989 8.39897L12 13.7979L6.60099 8.39897C6.26904 8.06702 5.73086 8.06702 5.39891 8.39897C5.06696 8.73091 5.06696 9.2691 5.39891 9.60105L11.3989 15.601C11.7309 15.933 12.269 15.933 12.601 15.601L18.601 9.60105C18.9329 9.2691 18.9329 8.73091 18.601 8.39897Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { PreviewMessageParams, PreviewMessageTheme } from '../types'
|
||||
|
||||
export type PreviewMessageProps = Pick<
|
||||
PreviewMessageParams,
|
||||
'avatarUrl' | 'message'
|
||||
> & {
|
||||
previewMessageTheme?: PreviewMessageTheme
|
||||
onClick: () => void
|
||||
onCloseClick: () => void
|
||||
}
|
||||
|
||||
const defaultBackgroundColor = '#F7F8FF'
|
||||
const defaultTextColor = '#303235'
|
||||
|
||||
export const PreviewMessage = (props: PreviewMessageProps) => {
|
||||
const [isPreviewMessageHovered, setIsPreviewMessageHovered] =
|
||||
createSignal(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => props.onClick()}
|
||||
class="fixed bottom-20 right-4 max-w-[256px] rounded-md duration-200 flex items-center gap-4 shadow-md animate-fade-in cursor-pointer hover:shadow-lg p-4"
|
||||
style={{
|
||||
'background-color':
|
||||
props.previewMessageTheme?.backgroundColor ?? defaultBackgroundColor,
|
||||
color: props.previewMessageTheme?.textColor ?? defaultTextColor,
|
||||
'z-index': 42424242,
|
||||
}}
|
||||
onMouseEnter={() => setIsPreviewMessageHovered(true)}
|
||||
onMouseLeave={() => setIsPreviewMessageHovered(false)}
|
||||
>
|
||||
<CloseButton
|
||||
isHovered={isPreviewMessageHovered()}
|
||||
previewMessageTheme={props.previewMessageTheme}
|
||||
onClick={props.onCloseClick}
|
||||
/>
|
||||
<Show when={props.avatarUrl} keyed>
|
||||
{(avatarUrl) => (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
class="rounded-full w-8 h-8 object-cover"
|
||||
alt="Bot avatar"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<p>{props.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CloseButton = (props: {
|
||||
isHovered: boolean
|
||||
previewMessageTheme?: PreviewMessageTheme
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
class={
|
||||
`absolute -top-2 -right-2 rounded-full w-6 h-6 p-1 hover:brightness-95 active:brightness-90 transition-all border ` +
|
||||
(props.isHovered ? 'opacity-100' : 'opacity-0')
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
return props.onClick()
|
||||
}}
|
||||
style={{
|
||||
'background-color':
|
||||
props.previewMessageTheme?.closeButtonBackgroundColor ??
|
||||
defaultBackgroundColor,
|
||||
color:
|
||||
props.previewMessageTheme?.closeButtonIconColor ?? defaultTextColor,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export * from './Bubble'
|
1
packages/embeds/js/src/features/bubble/index.ts
Normal file
1
packages/embeds/js/src/features/bubble/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
33
packages/embeds/js/src/features/bubble/types.ts
Normal file
33
packages/embeds/js/src/features/bubble/types.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type BubbleParams = {
|
||||
theme?: BubbleTheme
|
||||
previewMessage?: PreviewMessageParams
|
||||
}
|
||||
|
||||
export type BubbleTheme = {
|
||||
chatWindow?: ChatWindowTheme
|
||||
button?: ButtonTheme
|
||||
previewMessage?: PreviewMessageTheme
|
||||
}
|
||||
|
||||
export type ChatWindowTheme = {
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export type ButtonTheme = {
|
||||
backgroundColor?: string
|
||||
iconColor?: string
|
||||
customIconSrc?: string
|
||||
}
|
||||
|
||||
export type PreviewMessageParams = {
|
||||
avatarUrl?: string
|
||||
message: string
|
||||
autoShowDelay?: number
|
||||
}
|
||||
|
||||
export type PreviewMessageTheme = {
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
closeButtonBackgroundColor?: string
|
||||
closeButtonIconColor?: string
|
||||
}
|
2
packages/embeds/js/src/features/commands/index.ts
Normal file
2
packages/embeds/js/src/features/commands/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './utils'
|
21
packages/embeds/js/src/features/commands/types.ts
Normal file
21
packages/embeds/js/src/features/commands/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { PreviewMessageParams } from '../bubble/types'
|
||||
|
||||
export type CommandData = {
|
||||
isFromTypebot: boolean
|
||||
} & (
|
||||
| {
|
||||
command: 'open' | 'toggle' | 'close' | 'hidePreviewMessage'
|
||||
}
|
||||
| ShowMessageCommandData
|
||||
| SetPrefilledVariablesCommandData
|
||||
)
|
||||
|
||||
export type ShowMessageCommandData = {
|
||||
command: 'showPreviewMessage'
|
||||
message?: Pick<PreviewMessageParams, 'avatarUrl' | 'message'>
|
||||
}
|
||||
|
||||
export type SetPrefilledVariablesCommandData = {
|
||||
command: 'setPrefilledVariables'
|
||||
variables: Record<string, string | number | boolean>
|
||||
}
|
9
packages/embeds/js/src/features/commands/utils/close.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/close.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const close = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'close',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const hidePreviewMessage = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'hidePreviewMessage',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
6
packages/embeds/js/src/features/commands/utils/index.ts
Normal file
6
packages/embeds/js/src/features/commands/utils/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './close'
|
||||
export * from './hidePreviewMessage'
|
||||
export * from './open'
|
||||
export * from './setPrefilledVariables'
|
||||
export * from './showPreviewMessage'
|
||||
export * from './toggle'
|
9
packages/embeds/js/src/features/commands/utils/open.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/open.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const open = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'open',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const setPrefilledVariables = (
|
||||
variables: Record<string, string | number | boolean>
|
||||
) => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'setPrefilledVariables',
|
||||
variables,
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { CommandData, ShowMessageCommandData } from '../types'
|
||||
|
||||
export const showPreviewMessage = (
|
||||
proactiveMessage?: ShowMessageCommandData['message']
|
||||
) => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'showPreviewMessage',
|
||||
message: proactiveMessage,
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
9
packages/embeds/js/src/features/commands/utils/toggle.ts
Normal file
9
packages/embeds/js/src/features/commands/utils/toggle.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandData } from '../types'
|
||||
|
||||
export const toggle = () => {
|
||||
const message: CommandData = {
|
||||
isFromTypebot: true,
|
||||
command: 'toggle',
|
||||
}
|
||||
window.postMessage(message)
|
||||
}
|
134
packages/embeds/js/src/features/popup/components/Popup.tsx
Normal file
134
packages/embeds/js/src/features/popup/components/Popup.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import styles from '../../../assets/index.css'
|
||||
import {
|
||||
createSignal,
|
||||
onMount,
|
||||
Show,
|
||||
splitProps,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from 'solid-js'
|
||||
import { CommandData } from '../../commands'
|
||||
import { isDefined, isNotDefined } from '@typebot.io/lib'
|
||||
import { PopupParams } from '../types'
|
||||
import { Bot, BotProps } from '../../../components/Bot'
|
||||
|
||||
export type PopupProps = BotProps &
|
||||
PopupParams & {
|
||||
defaultOpen?: boolean
|
||||
isOpen?: boolean
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export const Popup = (props: PopupProps) => {
|
||||
const [popupProps, botProps] = splitProps(props, [
|
||||
'onOpen',
|
||||
'onClose',
|
||||
'autoShowDelay',
|
||||
'theme',
|
||||
'isOpen',
|
||||
'defaultOpen',
|
||||
])
|
||||
|
||||
const [prefilledVariables, setPrefilledVariables] = createSignal(
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
botProps.prefilledVariables
|
||||
)
|
||||
|
||||
const [isBotOpened, setIsBotOpened] = createSignal(
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
popupProps.isOpen ?? false
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
if (popupProps.defaultOpen) openBot()
|
||||
window.addEventListener('message', processIncomingEvent)
|
||||
const autoShowDelay = popupProps.autoShowDelay
|
||||
if (isDefined(autoShowDelay)) {
|
||||
setTimeout(() => {
|
||||
openBot()
|
||||
}, autoShowDelay)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('message', processIncomingEvent)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (isNotDefined(props.isOpen) || props.isOpen === isBotOpened()) return
|
||||
toggleBot()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.prefilledVariables) return
|
||||
setPrefilledVariables((existingPrefilledVariables) => ({
|
||||
...existingPrefilledVariables,
|
||||
...props.prefilledVariables,
|
||||
}))
|
||||
})
|
||||
|
||||
const stopPropagation = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
|
||||
const { data } = event
|
||||
if (!data.isFromTypebot) return
|
||||
if (data.command === 'open') openBot()
|
||||
if (data.command === 'close') closeBot()
|
||||
if (data.command === 'toggle') toggleBot()
|
||||
if (data.command === 'setPrefilledVariables')
|
||||
setPrefilledVariables((existingPrefilledVariables) => ({
|
||||
...existingPrefilledVariables,
|
||||
...data.variables,
|
||||
}))
|
||||
}
|
||||
|
||||
const openBot = () => {
|
||||
setIsBotOpened(true)
|
||||
popupProps.onOpen?.()
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.addEventListener('pointerdown', closeBot)
|
||||
}
|
||||
|
||||
const closeBot = () => {
|
||||
setIsBotOpened(false)
|
||||
popupProps.onClose?.()
|
||||
document.body.style.overflow = 'auto'
|
||||
document.removeEventListener('pointerdown', closeBot)
|
||||
}
|
||||
|
||||
const toggleBot = () => {
|
||||
isBotOpened() ? closeBot() : openBot()
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isBotOpened()}>
|
||||
<style>{styles}</style>
|
||||
<div
|
||||
class="relative z-10"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<style>{styles}</style>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in" />
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div
|
||||
class="relative h-[80vh] transform overflow-hidden rounded-lg text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
|
||||
style={{
|
||||
'background-color':
|
||||
props.theme?.backgroundColor ?? 'transparent',
|
||||
}}
|
||||
on:pointerdown={stopPropagation}
|
||||
>
|
||||
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './Popup'
|
1
packages/embeds/js/src/features/popup/index.ts
Normal file
1
packages/embeds/js/src/features/popup/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
7
packages/embeds/js/src/features/popup/types.ts
Normal file
7
packages/embeds/js/src/features/popup/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type PopupParams = {
|
||||
autoShowDelay?: number
|
||||
theme?: {
|
||||
width?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import styles from '../../../assets/index.css'
|
||||
import { Bot, BotProps } from '@/components/Bot'
|
||||
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
|
||||
const hostElementCss = `
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export const Standard = (
|
||||
props: BotProps,
|
||||
{ element }: { element: HTMLElement }
|
||||
) => {
|
||||
const [isBotDisplayed, setIsBotDisplayed] = createSignal(false)
|
||||
|
||||
const launchBot = () => {
|
||||
setIsBotDisplayed(true)
|
||||
}
|
||||
|
||||
const botLauncherObserver = new IntersectionObserver((intersections) => {
|
||||
if (intersections.some((intersection) => intersection.isIntersecting))
|
||||
launchBot()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
botLauncherObserver.observe(element)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
botLauncherObserver.disconnect()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{styles}
|
||||
{hostElementCss}
|
||||
</style>
|
||||
<Show when={isBotDisplayed()}>
|
||||
<Bot {...props} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './Standard'
|
1
packages/embeds/js/src/features/standard/index.ts
Normal file
1
packages/embeds/js/src/features/standard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components'
|
Reference in New Issue
Block a user