@ -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'
|
Reference in New Issue
Block a user