♻️ 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))
|
||||
}
|
Reference in New Issue
Block a user