⚗️ Implement bot v2 MVP (#194)

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

View File

@@ -0,0 +1,83 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { ChoiceInputBlock } from 'models'
import { createSignal, For } from 'solid-js'
type Props = {
inputIndex: number
block: ChoiceInputBlock
onSubmit: (value: InputSubmitContent) => void
}
export const ChoiceForm = (props: Props) => {
const [selectedIndices, setSelectedIndices] = createSignal<number[]>([])
const handleClick = (itemIndex: number) => (e: MouseEvent) => {
e.preventDefault()
if (props.block.options?.isMultipleChoice)
toggleSelectedItemIndex(itemIndex)
else props.onSubmit({ value: props.block.items[itemIndex].content ?? '' })
}
const toggleSelectedItemIndex = (itemIndex: number) => {
const existingIndex = selectedIndices().indexOf(itemIndex)
if (existingIndex !== -1) {
setSelectedIndices((selectedIndices) =>
selectedIndices.filter((index) => index !== itemIndex)
)
} else {
setSelectedIndices((selectedIndices) => [...selectedIndices, itemIndex])
}
}
const handleSubmit = () =>
props.onSubmit({
value: selectedIndices()
.map((itemIndex) => props.block.items[itemIndex].content)
.join(', '),
})
return (
<form class="flex flex-col items-end" onSubmit={handleSubmit}>
<div class="flex flex-wrap justify-end">
<For each={props.block.items}>
{(item, index) => (
<span class="relative inline-flex ml-2 mb-2">
<button
role={
props.block.options?.isMultipleChoice ? 'checkbox' : 'button'
}
onClick={(event) => handleClick(index())(event)}
class={
'py-2 px-4 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(selectedIndices().some(
(selectedIndex) => selectedIndex === index()
) || !props.block.options?.isMultipleChoice
? ''
: 'selectable')
}
data-testid="button"
data-itemid={item.id}
>
{item.content}
</button>
{props.inputIndex === 0 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
</span>
)}
</span>
)}
</For>
</div>
<div class="flex">
{selectedIndices().length > 0 && (
<SendButton disableIcon>
{props.block.options?.buttonLabel ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}

View File

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

View File

@@ -0,0 +1,100 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { DateInputOptions } from 'models'
import { createSignal } from 'solid-js'
import { parseReadableDate } from '../utils/parseReadableDate'
type Props = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
}
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal({ from: '', to: '' })
return (
<div class="flex flex-col w-full lg:w-4/6">
<div class="flex items-center">
<form
class={'flex justify-between rounded-lg typebot-input pr-2 items-end'}
onSubmit={(e) => {
if (inputValues().from === '' && inputValues().to === '') return
e.preventDefault()
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
label: parseReadableDate({
...inputValues(),
hasTime: props.options?.hasTime,
isRange: props.options?.isRange,
}),
})
}}
>
<div class="flex flex-col">
<div
class={
'flex items-center p-4 ' +
(props.options?.isRange ? 'pb-0' : '')
}
>
{props.options?.isRange && (
<p class="font-semibold mr-2">
{props.options.labels?.from ?? 'From:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
type={props.options?.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
from: e.currentTarget.value,
})
}
data-testid="from-date"
/>
</div>
{props.options?.isRange && (
<div class="flex items-center p-4">
{props.options.isRange && (
<p class="font-semibold">
{props.options.labels?.to ?? 'To:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input ml-2"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
to: e.currentTarget.value,
})
}
data-testid="to-date"
/>
</div>
)}
</div>
<SendButton
isDisabled={inputValues().to === '' && inputValues().from === ''}
class="my-2 ml-2"
>
{props.options?.labels?.button ?? 'Send'}
</SendButton>
</form>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,26 @@
export const parseReadableDate = ({
from,
to,
hasTime,
isRange,
}: {
from: string
to: string
hasTime?: boolean
isRange?: boolean
}) => {
const currentLocale = window.navigator.language
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: hasTime ? '2-digit' : undefined,
minute: hasTime ? '2-digit' : undefined,
}
const fromReadable = new Date(from).toLocaleString(
currentLocale,
formatOptions
)
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@@ -0,0 +1,65 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { EmailInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: EmailInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your email...'
}
onInput={handleInput}
type="email"
autocomplete="email"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@@ -0,0 +1,258 @@
import { SendButton, Spinner } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { FileInputBlock } from 'models'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from 'utils'
type Props = {
context: BotContext
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: () => void
}
export const FileUploadForm = (props: Props) => {
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
const [isUploading, setIsUploading] = createSignal(false)
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
const [errorMessage, setErrorMessage] = createSignal<string>()
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
if (
newFiles.some(
(file) =>
file.size > (props.block.options.sizeLimit ?? 10) * 1024 * 1024
)
)
return setErrorMessage(
`A file is larger than ${props.block.options.sizeLimit ?? 10}MB`
)
if (!props.block.options.isMultipleAllowed && files)
return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles(), ...newFiles])
}
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
if (selectedFiles().length === 0) return
startFilesUpload(selectedFiles())
}
const startSingleFileUpload = async (file: File) => {
if (props.context.isPreview)
return props.onSubmit({
label: `File uploaded`,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `/api/typebots/${props.context.typebotId}/blocks/${props.block.id}`,
files: [
{
file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
},
],
})
setIsUploading(false)
if (urls.length)
return props.onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
if (props.context.isPreview)
return props.onSubmit({
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
value: files
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
.join(', '),
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `/api/typebots/${props.context.typebotId}/blocks/${props.block.id}`,
files: files.map((file) => ({
file: file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
})),
onUploadProgress: setUploadProgressPercent,
})
setIsUploading(false)
setUploadProgressPercent(0)
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
props.onSubmit({
label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`,
value: urls.join(', '),
})
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDraggingOver(true)
}
const handleDragLeave = () => setIsDraggingOver(false)
const handleDropFile = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer?.files) return
onNewFiles(e.dataTransfer.files)
}
const clearFiles = () => setSelectedFiles([])
return (
<form class="flex flex-col w-full" onSubmit={handleSubmit}>
<label
for="dropzone-file"
class={
'typebot-upload-input py-6 flex flex-col justify-center items-center w-full bg-gray-50 rounded-lg border-2 border-gray-300 border-dashed cursor-pointer hover:bg-gray-100 px-8 mb-2 ' +
(isDraggingOver() ? 'dragging-over' : '')
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDropFile}
>
<Switch>
<Match when={isUploading()}>
<Show when={selectedFiles().length > 1} fallback={<Spinner />}>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="upload-progress-bar h-2.5 rounded-full"
style={{
width: `${
uploadProgressPercent() > 0 ? uploadProgressPercent : 10
}%`,
transition: 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
</Show>
</Match>
<Match when={!isUploading()}>
<>
<div class="flex flex-col justify-center items-center">
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
<span class="relative">
<FileIcon />
<div
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles().length}
</div>
</span>
</Show>
<p
class="text-sm text-gray-500 text-center"
innerHTML={props.block.options.labels.placeholder}
/>
</div>
<input
id="dropzone-file"
type="file"
class="hidden"
multiple={props.block.options.isMultipleAllowed}
onChange={(e) => {
if (!e.currentTarget.files) return
onNewFiles(e.currentTarget.files)
}}
/>
</>
</Match>
</Switch>
</label>
<Show
when={
selectedFiles().length === 0 &&
props.block.options.isRequired === false
}
>
<div class="flex justify-end">
<button
class={
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button '
}
onClick={() => props.onSkip()}
>
Skip
</button>
</div>
</Show>
<Show
when={
props.block.options.isMultipleAllowed &&
selectedFiles().length > 0 &&
!isUploading()
}
>
<div class="flex justify-end">
<div class="flex">
<Show when={selectedFiles().length}>
<button
class={
'secondary-button py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 mr-2'
}
onClick={clearFiles}
>
Clear
</button>
</Show>
<SendButton type="submit" disableIcon>
{props.block.options.labels.button
? `${props.block.options.labels.button} ${
selectedFiles().length
} file${selectedFiles().length > 1 ? 's' : ''}`
: 'Upload'}
</SendButton>
</div>
</div>
</Show>
<Show when={errorMessage()}>
<p class="text-red-500 text-sm">{errorMessage()}</p>
</Show>
</form>
)
}
const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3"
>
<polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" />
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
<polyline points="16 16 12 12 8 16" />
</svg>
)
const FileIcon = () => (
<svg
class="mb-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)

View File

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

View File

@@ -0,0 +1,68 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { NumberInputBlock } from 'models'
import { createSignal } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
onInput={handleInput}
type="number"
style={{ appearance: 'auto' }}
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? 'any'}
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@@ -0,0 +1,23 @@
import { BotContext } from '@/types'
import { PaymentInputOptions, PaymentProvider, RuntimeOptions } from 'models'
import { Match, Switch } from 'solid-js'
import { StripePaymentForm } from './StripePaymentForm'
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
export const PaymentForm = (props: Props) => (
<Switch>
<Match when={props.options.provider === PaymentProvider.STRIPE}>
<StripePaymentForm
// eslint-disable-next-line solid/reactivity
onSuccess={props.onSuccess}
options={props.options}
context={props.context}
/>
</Match>
</Switch>
)

View File

@@ -0,0 +1,119 @@
import { SendButton } from '@/components/SendButton'
import { createEffect, createSignal, Show } from 'solid-js'
import { Stripe, StripeElements } from '@stripe/stripe-js'
import { BotContext } from '@/types'
import { PaymentInputOptions, RuntimeOptions } from 'models'
import '@power-elements/stripe-elements'
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'stripe-payment-request': unknown
}
}
}
// TODO: Implement support for payment input. (WIP)
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
let stripe: Stripe | undefined
let elements: StripeElements | undefined
let ignoreFirstPaymentIntentCall = true
export const StripePaymentForm = (props: Props) => {
const [message, setMessage] = createSignal<string>()
const [isLoading, setIsLoading] = createSignal(false)
createEffect(() => {
if (!stripe) return
if (ignoreFirstPaymentIntentCall)
return (ignoreFirstPaymentIntentCall = false)
stripe
.retrievePaymentIntent(props.options.paymentIntentSecret)
.then(({ paymentIntent }) => {
switch (paymentIntent?.status) {
case 'succeeded':
setMessage('Payment succeeded!')
break
case 'processing':
setMessage('Your payment is processing.')
break
case 'requires_payment_method':
setMessage('Your payment was not successful, please try again.')
break
default:
setMessage('Something went wrong.')
break
}
})
})
const handleSubmit = async (event: Event & { submitter: HTMLElement }) => {
event.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
// TO-DO: Handle redirection correctly.
return_url: props.context.apiHost,
payment_method_data: {
billing_details: {
name: props.options.additionalInformation?.name,
email: props.options.additionalInformation?.email,
phone: props.options.additionalInformation?.phoneNumber,
},
},
},
redirect: 'if_required',
})
setIsLoading(false)
if (error?.type === 'validation_error') return
if (error?.type === 'card_error') return setMessage(error.message)
if (!error && paymentIntent.status === 'succeeded') return props.onSuccess()
}
return (
<form
id="payment-form"
onSubmit={handleSubmit}
class="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
>
{/* <stripe-payment-request
publishable-key={props.options.publicKey}
client-secret={props.options.paymentIntentSecret}
generate="source"
amount="125"
label="Double Double"
country="CA"
currency={props.options.currency}
/> */}
<SendButton
isLoading={isLoading() || !elements}
class="mt-4 w-full max-w-lg"
disableIcon
>
{props.options.labels.button} {props.options.amountLabel}
</SendButton>
<Show when={message()}>
<div
id="payment-message"
class="typebot-input-error-message mt-4 text-center"
>
{message()}
</div>
</Show>
</form>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { RatingInputBlock, RatingInputOptions } from 'models'
import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from 'utils'
type Props = {
block: RatingInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ? Number(props.block.prefilledValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
props.onSubmit({ value: rating.toString() })
}
const handleClick = (rating: number) => setRating(rating)
return (
<form class="flex flex-col" onSubmit={handleSubmit}>
{props.block.options.labels.left && (
<span class="text-sm w-full mb-2 rating-label">
{props.block.options.labels.left}
</span>
)}
<div class="flex flex-wrap justify-center">
<For
each={Array.from(
Array(
props.block.options.length +
(props.block.options.buttonType === 'Numbers' ? 1 : 0)
)
)}
>
{(_, idx) => (
<RatingButton
{...props.block.options}
rating={rating()}
idx={
idx() + (props.block.options.buttonType === 'Numbers' ? 0 : 1)
}
onClick={handleClick}
/>
)}
</For>
</div>
{props.block.options.labels.right && (
<span class="text-sm w-full text-right mb-2 pr-2 rating-label">
{props.block.options.labels.right}
</span>
)}
<div class="flex justify-end mr-2">
{isDefined(rating) && (
<SendButton disableIcon>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}
type RatingButtonProps = {
rating?: number
idx: number
onClick: (rating: number) => void
} & RatingInputOptions
const RatingButton = (props: RatingButtonProps) => {
return (
<Switch>
<Match when={props.buttonType === 'Numbers'}>
<button
onClick={(e) => {
e.preventDefault()
props.onClick(props.idx)
}}
class={
'py-2 px-4 mr-2 mb-2 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(isDefined(props.rating) && props.idx <= props.rating
? ''
: 'selectable')
}
>
{props.idx}
</button>
</Match>
<Match when={props.buttonType !== 'Numbers'}>
<div
class={
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
(isDefined(props.rating) && props.idx <= props.rating
? 'selected'
: '')
}
innerHTML={
props.customIcon.isEnabled && !isEmpty(props.customIcon.svg)
? props.customIcon.svg
: defaultIcon
}
onClick={() => props.onClick(props.idx)}
/>
</Match>
</Switch>
)
}
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`

View File

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

View File

@@ -0,0 +1,75 @@
import { Textarea, ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { TextInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: TextInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (props.block.options.isLong) return
if (e.key === 'Enter') submit()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': props.block.options.isLong ? undefined : '350px',
}}
onKeyDown={submitWhenEnter}
>
{props.block.options.isLong ? (
<Textarea
ref={inputRef as HTMLTextAreaElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
) : (
<ShortTextInput
ref={inputRef as HTMLInputElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
)}
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@@ -0,0 +1,71 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { UrlInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: UrlInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => {
if (!inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as HTMLInputElement}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your URL...'
}
onInput={handleInput}
type="url"
autocomplete="url"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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