refactor: ♻️ Rename step to block
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAnswers } from '../../../contexts/AnswersContext'
|
||||
import { InputBlock, InputBlockType } from 'models'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { TextForm } from './inputs/TextForm'
|
||||
import { byId } from 'utils'
|
||||
import { DateForm } from './inputs/DateForm'
|
||||
import { ChoiceForm } from './inputs/ChoiceForm'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { parseVariables } from '../../../services/variable'
|
||||
import { isInputValid } from 'services/inputs'
|
||||
import { PaymentForm } from './inputs/PaymentForm'
|
||||
import { RatingForm } from './inputs/RatingForm'
|
||||
|
||||
export const InputChatBlock = ({
|
||||
block,
|
||||
hasAvatar,
|
||||
hasGuestAvatar,
|
||||
onTransitionEnd,
|
||||
}: {
|
||||
block: InputBlock
|
||||
hasGuestAvatar: boolean
|
||||
hasAvatar: boolean
|
||||
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
|
||||
}) => {
|
||||
const { typebot } = useTypebot()
|
||||
const { addAnswer } = useAnswers()
|
||||
const [answer, setAnswer] = useState<string>()
|
||||
const [isEditting, setIsEditting] = useState(false)
|
||||
|
||||
const { variableId } = block.options
|
||||
const defaultValue =
|
||||
typebot.settings.general.isInputPrefillEnabled ?? true
|
||||
? variableId && typebot.variables.find(byId(variableId))?.value
|
||||
: undefined
|
||||
|
||||
const handleSubmit = async (content: string) => {
|
||||
setAnswer(content)
|
||||
const isRetry = !isInputValid(content, block.type)
|
||||
if (!isRetry && addAnswer)
|
||||
await addAnswer({
|
||||
blockId: block.id,
|
||||
groupId: block.groupId,
|
||||
content,
|
||||
variableId: variableId ?? null,
|
||||
})
|
||||
if (!isEditting) onTransitionEnd(content, isRetry)
|
||||
setIsEditting(false)
|
||||
}
|
||||
|
||||
const handleGuestBubbleClick = () => {
|
||||
setAnswer(undefined)
|
||||
setIsEditting(true)
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
const avatarUrl = typebot.theme.chat.guestAvatar?.url
|
||||
return (
|
||||
<GuestBubble
|
||||
message={answer}
|
||||
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
|
||||
avatarSrc={avatarUrl && parseVariables(typebot.variables)(avatarUrl)}
|
||||
onClick={handleGuestBubbleClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{hasAvatar && (
|
||||
<div className="flex w-6 xs:w-10 h-6 xs:h-10 mr-2 mb-2 mt-1 flex-shrink-0 items-center" />
|
||||
)}
|
||||
<Input
|
||||
block={block}
|
||||
onSubmit={handleSubmit}
|
||||
defaultValue={defaultValue?.toString()}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Input = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: {
|
||||
block: InputBlock
|
||||
onSubmit: (value: string) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}) => {
|
||||
switch (block.type) {
|
||||
case InputBlockType.TEXT:
|
||||
case InputBlockType.NUMBER:
|
||||
case InputBlockType.EMAIL:
|
||||
case InputBlockType.URL:
|
||||
case InputBlockType.PHONE:
|
||||
return (
|
||||
<TextForm
|
||||
block={block}
|
||||
onSubmit={onSubmit}
|
||||
defaultValue={defaultValue}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.DATE:
|
||||
return <DateForm options={block.options} onSubmit={onSubmit} />
|
||||
case InputBlockType.CHOICE:
|
||||
return <ChoiceForm block={block} onSubmit={onSubmit} />
|
||||
case InputBlockType.PAYMENT:
|
||||
return (
|
||||
<PaymentForm
|
||||
options={block.options}
|
||||
onSuccess={() => onSubmit(block.options.labels.success ?? 'Success')}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.RATING:
|
||||
return <RatingForm block={block} onSubmit={onSubmit} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { EmbedBubbleBlock } from 'models'
|
||||
import { TypingContent } from './TypingContent'
|
||||
import { parseVariables } from 'services/variable'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
|
||||
type Props = {
|
||||
block: EmbedBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const url = useMemo(
|
||||
() => parseVariables(typebot.variables)(block.content?.url),
|
||||
[block.content?.url, typebot.variables]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
showContentAfterMediaLoad()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const showContentAfterMediaLoad = () => {
|
||||
setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onTypingEnd = () => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full" ref={messageContainer}>
|
||||
<div className="flex mb-2 w-full lg:w-11/12 items-center">
|
||||
<div
|
||||
className={
|
||||
'flex relative z-10 items-start typebot-host-bubble w-full'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping ? '4rem' : '100%',
|
||||
height: isTyping ? '2rem' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping ? <TypingContent /> : <></>}
|
||||
</div>
|
||||
<iframe
|
||||
id="embed-bubble-content"
|
||||
src={url}
|
||||
className={
|
||||
'w-full z-20 p-4 content-opacity ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
height: isTyping ? '2rem' : block.content.height,
|
||||
borderRadius: '15px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Avatar } from 'components/avatars/Avatar'
|
||||
import React, { useState } from 'react'
|
||||
import { CSSTransition } from 'react-transition-group'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
showAvatar: boolean
|
||||
avatarSrc?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const GuestBubble = ({
|
||||
message,
|
||||
showAvatar,
|
||||
avatarSrc,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
const [content] = useState(message)
|
||||
|
||||
return (
|
||||
<CSSTransition classNames="bubble" timeout={1000}>
|
||||
<div
|
||||
className="flex justify-end mb-2 items-end"
|
||||
onClick={onClick}
|
||||
style={{ marginLeft: '50px' }}
|
||||
>
|
||||
<span
|
||||
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer hover:brightness-90 active:brightness-75"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BubbleBlock, BubbleBlockType } from 'models'
|
||||
import React from 'react'
|
||||
import { EmbedBubble } from './EmbedBubble'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
import { TextBubble } from './TextBubble'
|
||||
import { VideoBubble } from './VideoBubble'
|
||||
|
||||
type Props = {
|
||||
block: BubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const HostBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
switch (block.type) {
|
||||
case BubbleBlockType.TEXT:
|
||||
return <TextBubble block={block} onTransitionEnd={onTransitionEnd} />
|
||||
case BubbleBlockType.IMAGE:
|
||||
return <ImageBubble block={block} onTransitionEnd={onTransitionEnd} />
|
||||
case BubbleBlockType.VIDEO:
|
||||
return <VideoBubble block={block} onTransitionEnd={onTransitionEnd} />
|
||||
case BubbleBlockType.EMBED:
|
||||
return <EmbedBubble block={block} onTransitionEnd={onTransitionEnd} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ImageBubbleBlock } from 'models'
|
||||
import { TypingContent } from './TypingContent'
|
||||
import { parseVariables } from 'services/variable'
|
||||
|
||||
type Props = {
|
||||
block: ImageBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const image = useRef<HTMLImageElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const url = useMemo(
|
||||
() => parseVariables(typebot.variables)(block.content?.url),
|
||||
[block.content?.url, typebot.variables]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
showContentAfterMediaLoad()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const showContentAfterMediaLoad = () => {
|
||||
if (!image.current) return
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, mediaLoadingFallbackTimeout)
|
||||
image.current.onload = () => {
|
||||
clearTimeout(timeout)
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}
|
||||
}
|
||||
|
||||
const onTypingEnd = () => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" ref={messageContainer}>
|
||||
<div className="flex mb-2 w-full lg:w-11/12 items-center">
|
||||
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping ? '4rem' : '100%',
|
||||
height: isTyping ? '2rem' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping ? <TypingContent /> : <></>}
|
||||
</div>
|
||||
<img
|
||||
ref={image}
|
||||
src={url}
|
||||
className={
|
||||
'p-4 content-opacity z-10 w-auto ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
maxHeight: '32rem',
|
||||
height: isTyping ? '2rem' : 'auto',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
alt="Bubble image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { BubbleBlockType, TextBubbleBlock } from 'models'
|
||||
import { computeTypingTimeout } from 'services/chat'
|
||||
import { TypingContent } from './TypingContent'
|
||||
import { parseVariables } from 'services/variable'
|
||||
|
||||
type Props = {
|
||||
block: TextBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
const defaultTypingEmulation = {
|
||||
enabled: true,
|
||||
speed: 300,
|
||||
maxDelay: 1.5,
|
||||
}
|
||||
|
||||
export const TextBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const [content] = useState(
|
||||
parseVariables(typebot.variables)(block.content.html)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const typingTimeout = computeTypingTimeout(
|
||||
block.content.plainText,
|
||||
typebot.settings?.typingEmulation ?? defaultTypingEmulation
|
||||
)
|
||||
setTimeout(() => {
|
||||
onTypingEnd()
|
||||
}, typingTimeout)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onTypingEnd = () => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" ref={messageContainer}>
|
||||
<div className="flex mb-2 w-full items-center">
|
||||
<div className={'flex relative items-start typebot-host-bubble'}>
|
||||
<div
|
||||
className="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 ? <TypingContent /> : <></>}
|
||||
</div>
|
||||
{block.type === BubbleBlockType.TEXT && (
|
||||
<p
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
className={
|
||||
'overflow-hidden content-opacity mx-4 my-2 whitespace-pre-wrap slate-html-container relative ' +
|
||||
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
||||
}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export const TypingContent = (): JSX.Element => (
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 mr-1 rounded-full bubble1" />
|
||||
<div className="w-2 h-2 mr-1 rounded-full bubble2" />
|
||||
<div className="w-2 h-2 rounded-full bubble3" />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import {
|
||||
Variable,
|
||||
VideoBubbleContent,
|
||||
VideoBubbleContentType,
|
||||
VideoBubbleBlock,
|
||||
} from 'models'
|
||||
import { TypingContent } from './TypingContent'
|
||||
import { parseVariables } from 'services/variable'
|
||||
|
||||
type Props = {
|
||||
block: VideoBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
showContentAfterMediaLoad()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const showContentAfterMediaLoad = () => {
|
||||
setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onTypingEnd = () => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" ref={messageContainer}>
|
||||
<div className="flex mb-2 w-full lg:w-11/12 items-center">
|
||||
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping ? '4rem' : '100%',
|
||||
height: isTyping ? '2rem' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping ? <TypingContent /> : <></>}
|
||||
</div>
|
||||
<VideoContent
|
||||
content={block.content}
|
||||
isTyping={isTyping}
|
||||
variables={typebot.variables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VideoContent = ({
|
||||
content,
|
||||
isTyping,
|
||||
variables,
|
||||
}: {
|
||||
content?: VideoBubbleContent
|
||||
isTyping: boolean
|
||||
variables: Variable[]
|
||||
}) => {
|
||||
const url = useMemo(
|
||||
() => parseVariables(variables)(content?.url),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[variables]
|
||||
)
|
||||
if (!content?.type) return <></>
|
||||
switch (content.type) {
|
||||
case VideoBubbleContentType.URL:
|
||||
const isSafariBrowser = window.navigator.vendor.match(/apple/i)
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
className={
|
||||
'p-4 focus:outline-none w-full z-10 content-opacity rounded-md ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
height: isTyping ? '2rem' : 'auto',
|
||||
maxHeight: isSafariBrowser ? '40vh' : '',
|
||||
}}
|
||||
autoPlay
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Sorry, your browser doesn't support embedded videos.
|
||||
</video>
|
||||
)
|
||||
case VideoBubbleContentType.VIMEO:
|
||||
case VideoBubbleContentType.YOUTUBE: {
|
||||
const baseUrl =
|
||||
content.type === VideoBubbleContentType.VIMEO
|
||||
? 'https://player.vimeo.com/video'
|
||||
: 'https://www.youtube.com/embed'
|
||||
return (
|
||||
<iframe
|
||||
src={`${baseUrl}/${content.id}`}
|
||||
className={
|
||||
'w-full p-4 content-opacity z-10 rounded-md ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
height={isTyping ? '2rem' : '200px'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { InputChatBlock } from './InputChatBlock'
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useAnswers } from 'contexts/AnswersContext'
|
||||
import { ChoiceInputBlock } from 'models'
|
||||
import React, { useState } from 'react'
|
||||
import { SendButton } from './SendButton'
|
||||
|
||||
type ChoiceFormProps = {
|
||||
block: ChoiceInputBlock
|
||||
onSubmit: (value: string) => void
|
||||
}
|
||||
|
||||
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
|
||||
const { resultValues } = useAnswers()
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
|
||||
|
||||
const handleClick = (itemIndex: number) => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (block.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
|
||||
else onSubmit(block.items[itemIndex].content ?? '')
|
||||
}
|
||||
|
||||
const toggleSelectedItemIndex = (itemIndex: number) => {
|
||||
const existingIndex = selectedIndices.indexOf(itemIndex)
|
||||
if (existingIndex !== -1) {
|
||||
selectedIndices.splice(existingIndex, 1)
|
||||
setSelectedIndices([...selectedIndices])
|
||||
} else {
|
||||
setSelectedIndices([...selectedIndices, itemIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () =>
|
||||
onSubmit(
|
||||
selectedIndices
|
||||
.map((itemIndex) => block.items[itemIndex].content)
|
||||
.join(', ')
|
||||
)
|
||||
|
||||
const isUniqueFirstButton =
|
||||
resultValues &&
|
||||
resultValues.answers.length === 0 &&
|
||||
block.items.length === 1
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-wrap">
|
||||
{block.items.map((item, idx) => (
|
||||
<span key={item.id} className="relative inline-flex mr-2 mb-2">
|
||||
<button
|
||||
role={block.options?.isMultipleChoice ? 'checkbox' : 'button'}
|
||||
onClick={handleClick(idx)}
|
||||
className={
|
||||
'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.includes(idx) ||
|
||||
!block.options?.isMultipleChoice
|
||||
? ''
|
||||
: 'selectable')
|
||||
}
|
||||
data-testid="button"
|
||||
data-itemid={item.id}
|
||||
>
|
||||
{item.content}
|
||||
</button>
|
||||
{isUniqueFirstButton && (
|
||||
<span className="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 brightness-200" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{selectedIndices.length > 0 && (
|
||||
<SendButton
|
||||
label={block.options?.buttonLabel ?? 'Send'}
|
||||
disableIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DateInputOptions } from 'models'
|
||||
import React, { useState } from 'react'
|
||||
import { SendButton } from './SendButton'
|
||||
|
||||
type DateInputProps = {
|
||||
onSubmit: (inputValue: `${string} to ${string}` | string) => void
|
||||
options?: DateInputOptions
|
||||
}
|
||||
|
||||
export const DateForm = ({
|
||||
onSubmit,
|
||||
options,
|
||||
}: DateInputProps): JSX.Element => {
|
||||
const { hasTime, isRange, labels } = options ?? {}
|
||||
const [inputValues, setInputValues] = useState({ from: '', to: '' })
|
||||
return (
|
||||
<div className="flex flex-col w-full lg:w-4/6">
|
||||
<div className="flex items-center">
|
||||
<form
|
||||
className={
|
||||
'flex justify-between rounded-lg typebot-input pr-2 items-end'
|
||||
}
|
||||
onSubmit={(e) => {
|
||||
if (inputValues.from === '' && inputValues.to === '') return
|
||||
e.preventDefault()
|
||||
onSubmit(
|
||||
`${inputValues.from}${isRange ? ` to ${inputValues.to}` : ''}`
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className={'flex items-center p-4 ' + (isRange ? 'pb-0' : '')}>
|
||||
{isRange && (
|
||||
<p className="font-semibold mr-2">{labels?.from ?? 'From:'}</p>
|
||||
)}
|
||||
<input
|
||||
className="focus:outline-none flex-1 w-full text-input"
|
||||
style={{
|
||||
minHeight: '2rem',
|
||||
minWidth: '100px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
type={hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({ ...inputValues, from: e.target.value })
|
||||
}
|
||||
data-testid="from-date"
|
||||
/>
|
||||
</div>
|
||||
{isRange && (
|
||||
<div className="flex items-center p-4">
|
||||
{isRange && (
|
||||
<p className="font-semibold">{labels?.to ?? 'To:'}</p>
|
||||
)}
|
||||
<input
|
||||
className="focus:outline-none flex-1 w-full text-input ml-2"
|
||||
style={{
|
||||
minHeight: '2rem',
|
||||
minWidth: '100px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
type={hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({ ...inputValues, to: e.target.value })
|
||||
}
|
||||
data-testid="to-date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SendButton
|
||||
label={labels?.button ?? 'Send'}
|
||||
isDisabled={inputValues.to === '' && inputValues.from === ''}
|
||||
className="my-2 ml-2"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PaymentInputOptions, PaymentProvider } from 'models'
|
||||
import React from 'react'
|
||||
import { StripePaymentForm } from './StripePaymentForm'
|
||||
|
||||
type Props = {
|
||||
onSuccess: () => void
|
||||
options: PaymentInputOptions
|
||||
}
|
||||
|
||||
export const PaymentForm = ({ onSuccess, options }: Props): JSX.Element => {
|
||||
switch (options.provider) {
|
||||
case PaymentProvider.STRIPE:
|
||||
return <StripePaymentForm onSuccess={onSuccess} options={options} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { FormEvent, useEffect, useState } from 'react'
|
||||
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { createPaymentIntent } from 'services/stripe'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { PaymentInputOptions, Variable } from 'models'
|
||||
import { SendButton, Spinner } from '../SendButton'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { initStripe } from '../../../../../../lib/stripe'
|
||||
import { parseVariables } from 'services/variable'
|
||||
import { useChat } from 'contexts/ChatContext'
|
||||
|
||||
type Props = {
|
||||
options: PaymentInputOptions
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export const StripePaymentForm = ({ options, onSuccess }: Props) => {
|
||||
const {
|
||||
apiHost,
|
||||
isPreview,
|
||||
typebot: { variables },
|
||||
onNewLog,
|
||||
} = useTypebot()
|
||||
const { window: frameWindow, document: frameDocument } = useFrame()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [stripe, setStripe] = useState<any>(null)
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [amountLabel, setAmountLabel] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const { data, error } = await createPaymentIntent({
|
||||
apiHost,
|
||||
isPreview,
|
||||
variables,
|
||||
inputOptions: options,
|
||||
})
|
||||
if (error)
|
||||
return onNewLog({
|
||||
status: 'error',
|
||||
description: error.name + ' ' + error.message,
|
||||
details: error.message,
|
||||
})
|
||||
if (!data) return
|
||||
await initStripe(frameDocument)
|
||||
setStripe(frameWindow.Stripe(data.publicKey))
|
||||
setClientSecret(data.clientSecret)
|
||||
setAmountLabel(data.amountLabel)
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (!stripe || !clientSecret) return <Spinner className="text-blue-500" />
|
||||
return (
|
||||
<Elements stripe={stripe} options={{ clientSecret }}>
|
||||
<CheckoutForm
|
||||
onSuccess={onSuccess}
|
||||
clientSecret={clientSecret}
|
||||
amountLabel={amountLabel}
|
||||
options={options}
|
||||
variables={variables}
|
||||
viewerHost={apiHost}
|
||||
/>
|
||||
</Elements>
|
||||
)
|
||||
}
|
||||
|
||||
const CheckoutForm = ({
|
||||
onSuccess,
|
||||
clientSecret,
|
||||
amountLabel,
|
||||
options,
|
||||
variables,
|
||||
viewerHost,
|
||||
}: {
|
||||
onSuccess: () => void
|
||||
clientSecret: string
|
||||
amountLabel: string
|
||||
options: PaymentInputOptions
|
||||
variables: Variable[]
|
||||
viewerHost: string
|
||||
}) => {
|
||||
const { scroll } = useChat()
|
||||
const [ignoreFirstPaymentIntentCall, setIgnoreFirstPaymentIntentCall] =
|
||||
useState(true)
|
||||
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const [message, setMessage] = useState<string>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isPayButtonVisible, setIsPayButtonVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!stripe || !clientSecret) return
|
||||
|
||||
if (ignoreFirstPaymentIntentCall)
|
||||
return setIgnoreFirstPaymentIntentCall(false)
|
||||
|
||||
stripe.retrievePaymentIntent(clientSecret).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
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stripe, clientSecret])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!stripe || !elements) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
// Make sure to change this to your payment completion page
|
||||
return_url: viewerHost,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: options.additionalInformation?.name
|
||||
? parseVariables(variables)(options.additionalInformation?.name)
|
||||
: undefined,
|
||||
email: options.additionalInformation?.email
|
||||
? parseVariables(variables)(options.additionalInformation?.email)
|
||||
: undefined,
|
||||
phone: options.additionalInformation?.phoneNumber
|
||||
? parseVariables(variables)(
|
||||
options.additionalInformation?.phoneNumber
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
if (!error) return onSuccess()
|
||||
if (error.type === 'validation_error') return
|
||||
if (error?.type === 'card_error') return setMessage(error.message)
|
||||
setMessage('An unexpected error occured.')
|
||||
}
|
||||
|
||||
const showPayButton = () => {
|
||||
setIsPayButtonVisible(true)
|
||||
scroll()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="payment-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
|
||||
>
|
||||
<PaymentElement
|
||||
id="payment-element"
|
||||
className="w-full"
|
||||
onReady={showPayButton}
|
||||
/>
|
||||
{isPayButtonVisible && (
|
||||
<SendButton
|
||||
label={`${options.labels.button} ${amountLabel}`}
|
||||
isDisabled={isLoading || !stripe || !elements}
|
||||
isLoading={isLoading}
|
||||
className="mt-4 w-full max-w-lg"
|
||||
disableIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div
|
||||
id="payment-message"
|
||||
className="typebot-input-error-message mt-4 text-center"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PaymentForm } from './PaymentForm'
|
||||
@@ -0,0 +1,108 @@
|
||||
import { RatingInputOptions, RatingInputBlock } from 'models'
|
||||
import React, { FormEvent, useRef, useState } from 'react'
|
||||
import { isDefined, isEmpty, isNotDefined } from 'utils'
|
||||
import { SendButton } from './SendButton'
|
||||
|
||||
type Props = {
|
||||
block: RatingInputBlock
|
||||
onSubmit: (value: string) => void
|
||||
}
|
||||
|
||||
export const RatingForm = ({ block, onSubmit }: Props) => {
|
||||
const [rating, setRating] = useState<number>()
|
||||
const scaleElement = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isNotDefined(rating)) return
|
||||
onSubmit(rating.toString())
|
||||
}
|
||||
|
||||
const handleClick = (rating: number) => setRating(rating)
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col" ref={scaleElement}>
|
||||
<div className="flex">
|
||||
{Array.from(Array(block.options.length)).map((_, idx) => (
|
||||
<RatingButton
|
||||
{...block.options}
|
||||
key={idx}
|
||||
rating={rating}
|
||||
idx={idx + 1}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mr-2 mb-2">
|
||||
{<span className="text-sm w-full ">{block.options.labels.left}</span>}
|
||||
{!isEmpty(block.options.labels.middle) && (
|
||||
<span className="text-sm w-full text-center">
|
||||
{block.options.labels.middle}
|
||||
</span>
|
||||
)}
|
||||
{
|
||||
<span className="text-sm w-full text-right">
|
||||
{block.options.labels.right}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mr-2">
|
||||
{isDefined(rating) && (
|
||||
<SendButton
|
||||
label={block.options?.labels.button ?? 'Send'}
|
||||
disableIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const RatingButton = ({
|
||||
rating,
|
||||
idx,
|
||||
buttonType,
|
||||
customIcon,
|
||||
onClick,
|
||||
}: Pick<RatingInputOptions, 'buttonType' | 'customIcon'> & {
|
||||
rating: number | undefined
|
||||
idx: number
|
||||
onClick: (idx: number) => void
|
||||
}) => {
|
||||
if (buttonType === 'Numbers')
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onClick(idx)
|
||||
}}
|
||||
className={
|
||||
'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(rating) && idx <= rating ? '' : 'selectable')
|
||||
}
|
||||
>
|
||||
{idx}
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
|
||||
(isDefined(rating) && idx <= rating ? 'selected' : '')
|
||||
}
|
||||
onClick={() => onClick(idx)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
customIcon.isEnabled && !isEmpty(customIcon.svg)
|
||||
? customIcon.svg
|
||||
: defaultIcon,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,61 @@
|
||||
import React, { SVGProps } from 'react'
|
||||
import { SendIcon } from '../../../../assets/icons'
|
||||
|
||||
type SendButtonProps = {
|
||||
label: string
|
||||
isDisabled?: boolean
|
||||
isLoading?: boolean
|
||||
disableIcon?: boolean
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export const SendButton = ({
|
||||
label,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
disableIcon,
|
||||
...props
|
||||
}: SendButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled || isLoading}
|
||||
{...props}
|
||||
className={
|
||||
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button ' +
|
||||
props.className
|
||||
}
|
||||
>
|
||||
{isLoading && <Spinner className="text-white" />}
|
||||
<span className={'xs:flex ' + (disableIcon ? '' : 'hidden')}>
|
||||
{label}
|
||||
</span>
|
||||
<SendIcon
|
||||
className={'send-icon flex ' + (disableIcon ? 'hidden' : 'xs:hidden')}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const Spinner = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
className={'animate-spin -ml-1 mr-3 h-5 w-5 ' + props.className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
EmailInputBlock,
|
||||
InputBlockType,
|
||||
NumberInputBlock,
|
||||
PhoneNumberInputBlock,
|
||||
TextInputBlock,
|
||||
UrlInputBlock,
|
||||
} from 'models'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
import { SendButton } from '../SendButton'
|
||||
import { TextInput } from './TextInputContent'
|
||||
|
||||
type TextFormProps = {
|
||||
block:
|
||||
| TextInputBlock
|
||||
| EmailInputBlock
|
||||
| NumberInputBlock
|
||||
| UrlInputBlock
|
||||
| PhoneNumberInputBlock
|
||||
onSubmit: (value: string) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const TextForm = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: TextFormProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
|
||||
const isLongText = block.type === InputBlockType.TEXT && block.options?.isLong
|
||||
|
||||
const handleChange = (inputValue: string) => {
|
||||
if (block.type === InputBlockType.URL && !inputValue.startsWith('https://'))
|
||||
return inputValue === 'https:/'
|
||||
? undefined
|
||||
: setInputValue(`https://${inputValue}`)
|
||||
|
||||
setInputValue(inputValue)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (inputValue === '') return
|
||||
onSubmit(inputValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-end justify-between rounded-lg pr-2 typebot-input w-full"
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: isLongText ? undefined : '350px',
|
||||
}}
|
||||
>
|
||||
<TextInput block={block} onChange={handleChange} value={inputValue} />
|
||||
<SendButton
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
TextInputBlock,
|
||||
EmailInputBlock,
|
||||
NumberInputBlock,
|
||||
InputBlockType,
|
||||
UrlInputBlock,
|
||||
PhoneNumberInputBlock,
|
||||
} from 'models'
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import PhoneInput, { Value, Country } from 'react-phone-number-input'
|
||||
|
||||
type TextInputProps = {
|
||||
block:
|
||||
| TextInputBlock
|
||||
| EmailInputBlock
|
||||
| NumberInputBlock
|
||||
| UrlInputBlock
|
||||
| PhoneNumberInputBlock
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const TextInput = ({ block, value, onChange }: TextInputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return
|
||||
inputRef.current.focus()
|
||||
}, [])
|
||||
|
||||
const handleInputChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => onChange(e.target.value)
|
||||
|
||||
const handlePhoneNumberChange = (value?: Value | undefined) => {
|
||||
onChange(value as string)
|
||||
}
|
||||
|
||||
switch (block.type) {
|
||||
case InputBlockType.TEXT: {
|
||||
return block.options?.isLong ? (
|
||||
<LongTextInput
|
||||
ref={inputRef as unknown as RefObject<HTMLTextAreaElement>}
|
||||
value={value}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
) : (
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
name="typebot-short-text"
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-typebot-answer-value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
case InputBlockType.EMAIL: {
|
||||
return (
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your email...'
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
)
|
||||
}
|
||||
case InputBlockType.NUMBER: {
|
||||
return (
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
type="number"
|
||||
style={{ appearance: 'auto' }}
|
||||
min={block.options?.min}
|
||||
max={block.options?.max}
|
||||
step={block.options?.step ?? 'any'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case InputBlockType.URL: {
|
||||
return (
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={block.options?.labels?.placeholder ?? 'Type your URL...'}
|
||||
onChange={handleInputChange}
|
||||
type="url"
|
||||
autoComplete="url"
|
||||
/>
|
||||
)
|
||||
}
|
||||
case InputBlockType.PHONE: {
|
||||
return (
|
||||
<PhoneInput
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ref={inputRef as any}
|
||||
value={value}
|
||||
onChange={handlePhoneNumberChange}
|
||||
placeholder={
|
||||
block.options.labels.placeholder ?? 'Your phone number...'
|
||||
}
|
||||
defaultCountry={block.options.defaultCountryCode as Country}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ShortTextInput = React.forwardRef(
|
||||
(
|
||||
props: React.InputHTMLAttributes<HTMLInputElement>,
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
type="text"
|
||||
required
|
||||
style={{ fontSize: '16px' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
const LongTextInput = React.forwardRef(
|
||||
(
|
||||
props: {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: ChangeEventHandler
|
||||
},
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
rows={6}
|
||||
data-testid="textarea"
|
||||
required
|
||||
style={{ fontSize: '16px' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { TextForm } from './TextForm'
|
||||
Reference in New Issue
Block a user