♻️ Re-organize workspace folders
This commit is contained in:
@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom'],
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
}
|
@ -1 +0,0 @@
|
||||
# bot-engine
|
@ -1,53 +0,0 @@
|
||||
{
|
||||
"name": "bot-engine",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm tsc --noEmit && tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint --fix \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "1.16.4",
|
||||
"@stripe/stripe-js": "1.46.0",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.11.0",
|
||||
"react-frame-component": "5.2.6",
|
||||
"react-phone-number-input": "3.2.16",
|
||||
"react-scroll": "1.8.9",
|
||||
"react-transition-group": "4.4.5",
|
||||
"resize-observer": "1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.11.18",
|
||||
"@types/qs": "6.9.7",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-phone-number-input": "3.0.14",
|
||||
"@types/react-scroll": "1.8.6",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"autoprefixer": "10.4.13",
|
||||
"db": "workspace:*",
|
||||
"esbuild": "^0.17.5",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"models": "workspace:*",
|
||||
"postcss": "8.4.21",
|
||||
"prettier": "2.8.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwindcss": "3.2.4",
|
||||
"tsconfig": "workspace:*",
|
||||
"tsup": "6.5.0",
|
||||
"typebot-js": "workspace:*",
|
||||
"typescript": "4.9.4",
|
||||
"utils": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"db": "workspace:*",
|
||||
"react": "18.0.0",
|
||||
"react-dom": "18.0.0"
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
@ -1,3 +0,0 @@
|
||||
.lite-badge {
|
||||
display: block !important;
|
||||
}
|
@ -1,168 +0,0 @@
|
||||
/* CSS variables. */
|
||||
:root {
|
||||
--PhoneInput-color--focus: #03b2cb;
|
||||
--PhoneInputInternationalIconPhone-opacity: 0.8;
|
||||
--PhoneInputInternationalIconGlobe-opacity: 0.65;
|
||||
--PhoneInputCountrySelect-marginRight: 0.35em;
|
||||
--PhoneInputCountrySelectArrow-width: 0.3em;
|
||||
--PhoneInputCountrySelectArrow-marginLeft: var(
|
||||
--PhoneInputCountrySelect-marginRight
|
||||
);
|
||||
--PhoneInputCountrySelectArrow-borderWidth: 1px;
|
||||
--PhoneInputCountrySelectArrow-opacity: 0.45;
|
||||
--PhoneInputCountrySelectArrow-color: currentColor;
|
||||
--PhoneInputCountrySelectArrow-color--focus: var(--PhoneInput-color--focus);
|
||||
--PhoneInputCountrySelectArrow-transform: rotate(45deg);
|
||||
--PhoneInputCountryFlag-aspectRatio: 1.5;
|
||||
--PhoneInputCountryFlag-height: 1em;
|
||||
--PhoneInputCountryFlag-borderWidth: 1px;
|
||||
--PhoneInputCountryFlag-borderColor: rgba(0, 0, 0, 0.5);
|
||||
--PhoneInputCountryFlag-borderColor--focus: var(--PhoneInput-color--focus);
|
||||
--PhoneInputCountryFlag-backgroundColor--loading: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.PhoneInput {
|
||||
/* This is done to stretch the contents of this component. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.PhoneInputInput {
|
||||
/* The phone number input stretches to fill all empty space */
|
||||
flex: 1;
|
||||
/* The phone number input should shrink
|
||||
to make room for the extension input */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon {
|
||||
width: calc(
|
||||
var(--PhoneInputCountryFlag-height) *
|
||||
var(--PhoneInputCountryFlag-aspectRatio)
|
||||
);
|
||||
height: var(--PhoneInputCountryFlag-height);
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon--square {
|
||||
width: var(--PhoneInputCountryFlag-height);
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon--border {
|
||||
/* Removed `background-color` because when an `<img/>` was still loading
|
||||
it would show a dark gray rectangle. */
|
||||
/* For some reason the `<img/>` is not stretched to 100% width and height
|
||||
and sometime there can be seen white pixels of the background at top and bottom. */
|
||||
background-color: var(--PhoneInputCountryFlag-backgroundColor--loading);
|
||||
/* Border is added via `box-shadow` because `border` interferes with `width`/`height`. */
|
||||
/* For some reason the `<img/>` is not stretched to 100% width and height
|
||||
and sometime there can be seen white pixels of the background at top and bottom,
|
||||
so an additional "inset" border is added. */
|
||||
box-shadow: 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor),
|
||||
inset 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor);
|
||||
}
|
||||
|
||||
.PhoneInputCountryIconImg {
|
||||
/* Fixes weird vertical space above the flag icon. */
|
||||
/* https://gitlab.com/catamphetamine/react-phone-number-input/-/issues/7#note_348586559 */
|
||||
display: block;
|
||||
/* 3rd party <SVG/> flag icons won't stretch if they have `width` and `height`.
|
||||
Also, if an <SVG/> icon's aspect ratio was different, it wouldn't fit too. */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.PhoneInputInternationalIconPhone {
|
||||
opacity: var(--PhoneInputInternationalIconPhone-opacity);
|
||||
}
|
||||
|
||||
.PhoneInputInternationalIconGlobe {
|
||||
opacity: var(--PhoneInputInternationalIconGlobe-opacity);
|
||||
}
|
||||
|
||||
/* Styling native country `<select/>`. */
|
||||
|
||||
.PhoneInputCountry {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: var(--PhoneInputCountrySelect-marginRight);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect[disabled],
|
||||
.PhoneInputCountrySelect[readonly] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelectArrow {
|
||||
display: block;
|
||||
content: '';
|
||||
width: var(--PhoneInputCountrySelectArrow-width);
|
||||
height: var(--PhoneInputCountrySelectArrow-width);
|
||||
margin-left: var(--PhoneInputCountrySelectArrow-marginLeft);
|
||||
border-style: solid;
|
||||
border-color: var(--PhoneInputCountrySelectArrow-color);
|
||||
border-top-width: 0;
|
||||
border-bottom-width: var(--PhoneInputCountrySelectArrow-borderWidth);
|
||||
border-left-width: 0;
|
||||
border-right-width: var(--PhoneInputCountrySelectArrow-borderWidth);
|
||||
transform: var(--PhoneInputCountrySelectArrow-transform);
|
||||
opacity: var(--PhoneInputCountrySelectArrow-opacity);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus
|
||||
+ .PhoneInputCountryIcon
|
||||
+ .PhoneInputCountrySelectArrow {
|
||||
opacity: 1;
|
||||
color: var(--PhoneInputCountrySelectArrow-color--focus);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus + .PhoneInputCountryIcon--border {
|
||||
box-shadow: 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor--focus),
|
||||
inset 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor--focus);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus
|
||||
+ .PhoneInputCountryIcon
|
||||
.PhoneInputInternationalIconGlobe {
|
||||
opacity: 1;
|
||||
color: var(--PhoneInputCountrySelectArrow-color--focus);
|
||||
}
|
||||
|
||||
.PhoneInputInput {
|
||||
padding: 1rem 0.5rem;
|
||||
outline: none !important;
|
||||
background: transparent;
|
||||
flex: 1 1 0%;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.PhoneInputCountry {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon,
|
||||
.PhoneInputCountryIconImg {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input.PhoneInputInput::placeholder {
|
||||
color: var(--typebot-input-placeholder-color);
|
||||
}
|
@ -1,243 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--typebot-container-bg-image: none;
|
||||
--typebot-container-bg-color: transparent;
|
||||
--typebot-container-font-family: 'Open Sans';
|
||||
|
||||
--typebot-button-bg-color: #0042da;
|
||||
--typebot-button-color: #ffffff;
|
||||
|
||||
--typebot-host-bubble-bg-color: #f7f8ff;
|
||||
--typebot-host-bubble-color: #303235;
|
||||
|
||||
--typebot-guest-bubble-bg-color: #ff8e21;
|
||||
--typebot-guest-bubble-color: #ffffff;
|
||||
|
||||
--typebot-input-bg-color: #ffffff;
|
||||
--typebot-input-color: #303235;
|
||||
--typebot-input-placeholder-color: #9095a0;
|
||||
|
||||
--typebot-header-bg-color: #ffffff;
|
||||
--typebot-header-color: #303235;
|
||||
|
||||
/* Phone input */
|
||||
--PhoneInputCountryFlag-borderColor: transparent;
|
||||
--PhoneInput-color--focus: transparent;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.scrollable-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.scrollable-container {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.bubble-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.bubble-enter-active {
|
||||
opacity: 1;
|
||||
transition-property: opacity;
|
||||
transition-duration: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.bubble-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
.bubble-exit-active {
|
||||
opacity: 0;
|
||||
transition-delay: 0ms !important;
|
||||
transition-property: opacity;
|
||||
transition-duration: 400ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.bubble-typing {
|
||||
transition: width 400ms ease-out, height 400ms ease-out;
|
||||
}
|
||||
|
||||
.content-opacity {
|
||||
transition: opacity 400ms ease-in 200ms;
|
||||
}
|
||||
|
||||
.bubble1,
|
||||
.bubble2,
|
||||
.bubble3 {
|
||||
background-color: var(--typebot-host-bubble-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bubble1 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bubble2 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.bubble3 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes chatBubbles {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.slate-a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.slate-html-container > div {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.slate-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.slate-italic {
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
.slate-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-input::-webkit-input-placeholder {
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.text-input::-moz-placeholder {
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.text-input::placeholder {
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.typebot-container {
|
||||
background-image: var(--typebot-container-bg-image);
|
||||
background-color: var(--typebot-container-bg-color);
|
||||
font-family: var(--typebot-container-font-family);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
color: var(--typebot-header-color);
|
||||
background-color: var(--typebot-header-bg-color);
|
||||
}
|
||||
|
||||
.typebot-button {
|
||||
color: var(--typebot-button-color);
|
||||
background-color: var(--typebot-button-bg-color);
|
||||
border: 1px solid var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.typebot-button.selectable {
|
||||
color: var(--typebot-host-bubble-color);
|
||||
background-color: var(--typebot-host-bubble-bg-color);
|
||||
border: 1px solid var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.typebot-host-bubble {
|
||||
color: var(--typebot-host-bubble-color);
|
||||
}
|
||||
|
||||
.typebot-host-bubble > .bubble-typing {
|
||||
background-color: var(--typebot-host-bubble-bg-color);
|
||||
border: var(--typebot-host-bubble-border);
|
||||
}
|
||||
|
||||
.typebot-guest-bubble {
|
||||
color: var(--typebot-guest-bubble-color);
|
||||
background-color: var(--typebot-guest-bubble-bg-color);
|
||||
}
|
||||
|
||||
.typebot-input {
|
||||
color: var(--typebot-input-color);
|
||||
background-color: var(--typebot-input-bg-color);
|
||||
box-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typebot-input-error-message {
|
||||
color: var(--typebot-input-color);
|
||||
}
|
||||
|
||||
.typebot-button > .send-icon {
|
||||
fill: var(--typebot-button-color);
|
||||
}
|
||||
|
||||
.typebot-chat-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.ping span {
|
||||
background-color: var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.rating-icon-container svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
stroke: var(--typebot-button-bg-color);
|
||||
fill: var(--typebot-host-bubble-bg-color);
|
||||
transition: fill 100ms ease-out;
|
||||
}
|
||||
|
||||
.rating-icon-container.selected svg {
|
||||
fill: var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.rating-icon-container:hover svg {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.rating-icon-container:active svg {
|
||||
filter: brightness(0.75);
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
background-color: var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.total-files-indicator {
|
||||
background-color: var(--typebot-button-bg-color);
|
||||
color: var(--typebot-button-color);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.typebot-upload-input {
|
||||
transition: border-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.typebot-upload-input.dragging-over {
|
||||
border-color: var(--typebot-button-bg-color);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background-color: var(--typebot-host-bubble-bg-color);
|
||||
color: var(--typebot-host-bubble-color);
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { CSSTransition } from 'react-transition-group'
|
||||
import { ResizeObserver } from 'resize-observer'
|
||||
|
||||
type Props = { hostAvatarSrc?: string; keepShowing: boolean }
|
||||
|
||||
export const AvatarSideContainer = forwardRef(function AvatarSideContainer(
|
||||
{ hostAvatarSrc, keepShowing }: Props,
|
||||
ref: ForwardedRef<unknown>
|
||||
) {
|
||||
const { document } = useFrame()
|
||||
const [show, setShow] = useState(false)
|
||||
const [avatarTopOffset, setAvatarTopOffset] = useState(0)
|
||||
|
||||
const refreshTopOffset = () => {
|
||||
if (!scrollingSideGroupRef.current || !avatarContainer.current) return
|
||||
const { height } = scrollingSideGroupRef.current.getBoundingClientRect()
|
||||
const { height: avatarHeight } =
|
||||
avatarContainer.current.getBoundingClientRect()
|
||||
setAvatarTopOffset(height - avatarHeight)
|
||||
}
|
||||
const scrollingSideGroupRef = useRef<HTMLDivElement>(null)
|
||||
const avatarContainer = useRef<HTMLDivElement>(null)
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshTopOffset,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
setShow(true)
|
||||
const resizeObserver = new ResizeObserver(refreshTopOffset)
|
||||
resizeObserver.observe(document.body)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container "
|
||||
ref={scrollingSideGroupRef}
|
||||
>
|
||||
<CSSTransition
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
in={show && keepShowing}
|
||||
unmountOnExit
|
||||
>
|
||||
<div
|
||||
className="absolute w-6 xs:w-10 h-6 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
|
||||
ref={avatarContainer}
|
||||
style={{
|
||||
top: `${avatarTopOffset}px`,
|
||||
transition: 'top 350ms ease-out, opacity 500ms',
|
||||
}}
|
||||
>
|
||||
<Avatar avatarSrc={hostAvatarSrc} />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</div>
|
||||
)
|
||||
})
|
@ -1,174 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAnswers } from '../../../providers/AnswersProvider'
|
||||
import { InputBlock, InputBlockType } from 'models'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { byId } from 'utils'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { isInputValid } from '@/utils/inputs'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { TextInput } from '@/features/blocks/inputs/textInput'
|
||||
import { NumberInput } from '@/features/blocks/inputs/number'
|
||||
import { EmailInput } from '@/features/blocks/inputs/email'
|
||||
import { UrlInput } from '@/features/blocks/inputs/url'
|
||||
import { PhoneInput } from '@/features/blocks/inputs/phone'
|
||||
import { DateForm } from '@/features/blocks/inputs/date'
|
||||
import { ChoiceForm } from '@/features/blocks/inputs/buttons'
|
||||
import { PaymentForm } from '@/features/blocks/inputs/payment'
|
||||
import { RatingForm } from '@/features/blocks/inputs/rating'
|
||||
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
|
||||
|
||||
export const InputChatBlock = ({
|
||||
block,
|
||||
hasAvatar,
|
||||
hasGuestAvatar,
|
||||
onTransitionEnd,
|
||||
onSkip,
|
||||
}: {
|
||||
block: InputBlock
|
||||
hasGuestAvatar: boolean
|
||||
hasAvatar: boolean
|
||||
onTransitionEnd: (
|
||||
answerContent?: InputSubmitContent,
|
||||
isRetry?: boolean
|
||||
) => void
|
||||
onSkip: () => void
|
||||
}) => {
|
||||
const { typebot, isLoading } = 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(
|
||||
(variable) =>
|
||||
variable.name === typebot.variables.find(byId(variableId))?.name
|
||||
)?.value
|
||||
: undefined
|
||||
|
||||
const handleSubmit = async ({ label, value, itemId }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
const isRetry = !isInputValid(value, block.type)
|
||||
if (!isRetry && addAnswer)
|
||||
await addAnswer(typebot.variables)({
|
||||
blockId: block.id,
|
||||
groupId: block.groupId,
|
||||
content: value,
|
||||
variableId,
|
||||
uploadedFiles: block.type === InputBlockType.FILE,
|
||||
})
|
||||
if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry)
|
||||
setIsEditting(false)
|
||||
}
|
||||
|
||||
if (isLoading) return null
|
||||
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{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}
|
||||
onSkip={onSkip}
|
||||
defaultValue={defaultValue?.toString()}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Input = ({
|
||||
block,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: {
|
||||
block: InputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}) => {
|
||||
switch (block.type) {
|
||||
case InputBlockType.TEXT:
|
||||
return (
|
||||
<TextInput
|
||||
block={block}
|
||||
onSubmit={onSubmit}
|
||||
defaultValue={defaultValue}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.NUMBER:
|
||||
return (
|
||||
<NumberInput
|
||||
block={block}
|
||||
onSubmit={onSubmit}
|
||||
defaultValue={defaultValue}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.EMAIL:
|
||||
return (
|
||||
<EmailInput
|
||||
block={block}
|
||||
onSubmit={onSubmit}
|
||||
defaultValue={defaultValue}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.URL:
|
||||
return (
|
||||
<UrlInput
|
||||
block={block}
|
||||
onSubmit={onSubmit}
|
||||
defaultValue={defaultValue}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.PHONE:
|
||||
return (
|
||||
<PhoneInput
|
||||
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({ value: block.options.labels.success ?? 'Success' })
|
||||
}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.RATING:
|
||||
return <RatingForm block={block} onSubmit={onSubmit} />
|
||||
case InputBlockType.FILE:
|
||||
return (
|
||||
<FileUploadForm block={block} onSubmit={onSubmit} onSkip={onSkip} />
|
||||
)
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
export const GuestBubble = ({
|
||||
message,
|
||||
showAvatar,
|
||||
avatarSrc,
|
||||
}: Props): JSX.Element => {
|
||||
const [content] = useState(message)
|
||||
|
||||
return (
|
||||
<CSSTransition classNames="bubble" timeout={1000}>
|
||||
<div
|
||||
className="flex justify-end mb-2 items-end"
|
||||
style={{ marginLeft: '50px' }}
|
||||
>
|
||||
<span
|
||||
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
)
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { AudioBubble } from '@/features/blocks/bubbles/audio'
|
||||
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
|
||||
import { ImageBubble } from '@/features/blocks/bubbles/image'
|
||||
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
|
||||
import { VideoBubble } from '@/features/blocks/bubbles/video'
|
||||
import { BubbleBlock, BubbleBlockType } from 'models'
|
||||
|
||||
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} />
|
||||
case BubbleBlockType.AUDIO:
|
||||
return (
|
||||
<AudioBubble
|
||||
url={block.content.url}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,345 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
|
||||
import {
|
||||
isBubbleBlock,
|
||||
isBubbleBlockType,
|
||||
isChoiceInput,
|
||||
isDefined,
|
||||
isInputBlock,
|
||||
isIntegrationBlock,
|
||||
isLogicBlock,
|
||||
byId,
|
||||
} from 'utils'
|
||||
import {
|
||||
BubbleBlock,
|
||||
InputBlock,
|
||||
LogicBlockType,
|
||||
PublicTypebot,
|
||||
Block,
|
||||
} from 'models'
|
||||
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
|
||||
import { InputChatBlock } from './ChatBlock/InputChatBlock'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { useAnswers } from '@/providers/AnswersProvider'
|
||||
import { useChat } from '@/providers/ChatProvider'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { getLastChatBlockType } from '@/utils/chat'
|
||||
import { executeIntegration } from '@/utils/executeIntegration'
|
||||
import { executeLogic } from '@/utils/executeLogic'
|
||||
import { blockCanBeRetried, parseRetryBlock } from '@/utils/inputs'
|
||||
import { PopupBlockedToast } from '../PopupBlockedToast'
|
||||
|
||||
type ChatGroupProps = {
|
||||
blocks: Block[]
|
||||
startBlockIndex: number
|
||||
groupTitle: string
|
||||
keepShowingHostAvatar: boolean
|
||||
onGroupEnd: ({
|
||||
edgeId,
|
||||
updatedTypebot,
|
||||
}: {
|
||||
edgeId?: string
|
||||
updatedTypebot?: PublicTypebot | LinkedTypebot
|
||||
}) => void
|
||||
}
|
||||
|
||||
type ChatDisplayChunk = { bubbles: BubbleBlock[]; input?: InputBlock }
|
||||
|
||||
export const ChatGroup = ({
|
||||
blocks,
|
||||
startBlockIndex,
|
||||
groupTitle,
|
||||
onGroupEnd,
|
||||
keepShowingHostAvatar,
|
||||
}: ChatGroupProps) => {
|
||||
const {
|
||||
currentTypebotId,
|
||||
typebot,
|
||||
updateVariableValue,
|
||||
createEdge,
|
||||
apiHost,
|
||||
isPreview,
|
||||
parentTypebotIds,
|
||||
onNewLog,
|
||||
injectLinkedTypebot,
|
||||
linkedTypebots,
|
||||
setCurrentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
pushParentTypebotId,
|
||||
} = useTypebot()
|
||||
const { resultValues, updateVariables, resultId } = useAnswers()
|
||||
const { scroll } = useChat()
|
||||
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
|
||||
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
|
||||
const [blockedPopupUrl, setBlockedPopupUrl] = useState<string>()
|
||||
|
||||
const insertBlockInStack = (nextBlock: Block) => {
|
||||
setProcessedBlocks([...processedBlocks, nextBlock])
|
||||
if (isBubbleBlock(nextBlock)) {
|
||||
const lastBlockType = getLastChatBlockType(processedBlocks)
|
||||
lastBlockType && isBubbleBlockType(lastBlockType)
|
||||
? setDisplayedChunks(
|
||||
displayedChunks.map((c, idx) =>
|
||||
idx === displayedChunks.length - 1
|
||||
? { bubbles: [...c.bubbles, nextBlock] }
|
||||
: c
|
||||
)
|
||||
)
|
||||
: setDisplayedChunks([...displayedChunks, { bubbles: [nextBlock] }])
|
||||
}
|
||||
if (isInputBlock(nextBlock)) {
|
||||
displayedChunks.length === 0 ||
|
||||
isDefined(displayedChunks[displayedChunks.length - 1].input)
|
||||
? setDisplayedChunks([
|
||||
...displayedChunks,
|
||||
{ bubbles: [], input: nextBlock },
|
||||
])
|
||||
: setDisplayedChunks(
|
||||
displayedChunks.map((c, idx) =>
|
||||
idx === displayedChunks.length - 1
|
||||
? { ...c, input: nextBlock }
|
||||
: c
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const nextBlock = blocks[startBlockIndex]
|
||||
if (nextBlock) insertBlockInStack(nextBlock)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scroll()
|
||||
onNewBlockDisplayed()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processedBlocks])
|
||||
|
||||
const onNewBlockDisplayed = async () => {
|
||||
const currentBlock = [...processedBlocks].pop()
|
||||
if (!currentBlock) return
|
||||
if (isLogicBlock(currentBlock)) {
|
||||
const { nextEdgeId, linkedTypebot, blockedPopupUrl } = await executeLogic(
|
||||
currentBlock,
|
||||
{
|
||||
isPreview,
|
||||
apiHost,
|
||||
typebot,
|
||||
linkedTypebots,
|
||||
updateVariableValue,
|
||||
updateVariables,
|
||||
injectLinkedTypebot,
|
||||
onNewLog,
|
||||
createEdge,
|
||||
setCurrentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
currentTypebotId,
|
||||
pushParentTypebotId,
|
||||
}
|
||||
)
|
||||
if (blockedPopupUrl) setBlockedPopupUrl(blockedPopupUrl)
|
||||
const isRedirecting =
|
||||
currentBlock.type === LogicBlockType.REDIRECT &&
|
||||
currentBlock.options.isNewTab === false
|
||||
if (isRedirecting) return
|
||||
nextEdgeId
|
||||
? onGroupEnd({ edgeId: nextEdgeId, updatedTypebot: linkedTypebot })
|
||||
: displayNextBlock()
|
||||
}
|
||||
if (isIntegrationBlock(currentBlock)) {
|
||||
const nextEdgeId = await executeIntegration({
|
||||
block: currentBlock,
|
||||
context: {
|
||||
apiHost,
|
||||
typebotId: currentTypebotId,
|
||||
groupId: currentBlock.groupId,
|
||||
blockId: currentBlock.id,
|
||||
variables: typebot.variables,
|
||||
isPreview,
|
||||
updateVariableValue,
|
||||
updateVariables,
|
||||
resultValues,
|
||||
groups: typebot.groups,
|
||||
onNewLog,
|
||||
resultId,
|
||||
parentTypebotIds,
|
||||
},
|
||||
})
|
||||
nextEdgeId ? onGroupEnd({ edgeId: nextEdgeId }) : displayNextBlock()
|
||||
}
|
||||
if (currentBlock.type === 'start')
|
||||
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
|
||||
}
|
||||
|
||||
const displayNextBlock = (
|
||||
answerContent?: InputSubmitContent,
|
||||
isRetry?: boolean
|
||||
) => {
|
||||
scroll()
|
||||
const currentBlock = [...processedBlocks].pop()
|
||||
if (currentBlock) {
|
||||
if (isRetry && blockCanBeRetried(currentBlock))
|
||||
return insertBlockInStack(
|
||||
parseRetryBlock(currentBlock, typebot.variables, createEdge)
|
||||
)
|
||||
if (
|
||||
isInputBlock(currentBlock) &&
|
||||
currentBlock.options?.variableId &&
|
||||
answerContent
|
||||
) {
|
||||
updateVariableValue(
|
||||
currentBlock.options.variableId,
|
||||
answerContent.value
|
||||
)
|
||||
}
|
||||
const isSingleChoiceBlock =
|
||||
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
|
||||
if (isSingleChoiceBlock) {
|
||||
const nextEdgeId = currentBlock.items.find(
|
||||
byId(answerContent?.itemId)
|
||||
)?.outgoingEdgeId
|
||||
if (nextEdgeId) return onGroupEnd({ edgeId: nextEdgeId })
|
||||
}
|
||||
|
||||
if (
|
||||
currentBlock?.outgoingEdgeId ||
|
||||
processedBlocks.length === blocks.length
|
||||
)
|
||||
return onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
|
||||
}
|
||||
const nextBlock = blocks[processedBlocks.length + startBlockIndex]
|
||||
nextBlock ? insertBlockInStack(nextBlock) : onGroupEnd({})
|
||||
}
|
||||
|
||||
const avatarSrc = typebot.theme.chat.hostAvatar?.url
|
||||
|
||||
return (
|
||||
<div className="flex w-full" data-group-name={groupTitle}>
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
{displayedChunks.map((chunk, idx) => (
|
||||
<ChatChunks
|
||||
key={idx}
|
||||
displayChunk={chunk}
|
||||
hostAvatar={{
|
||||
isEnabled: typebot.theme.chat.hostAvatar?.isEnabled ?? true,
|
||||
src: avatarSrc && parseVariables(typebot.variables)(avatarSrc),
|
||||
}}
|
||||
hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
|
||||
onDisplayNextBlock={displayNextBlock}
|
||||
keepShowingHostAvatar={keepShowingHostAvatar}
|
||||
blockedPopupUrl={blockedPopupUrl}
|
||||
onBlockedPopupLinkClick={() => setBlockedPopupUrl(undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
displayChunk: ChatDisplayChunk
|
||||
hostAvatar: { isEnabled: boolean; src?: string }
|
||||
hasGuestAvatar: boolean
|
||||
keepShowingHostAvatar: boolean
|
||||
blockedPopupUrl?: string
|
||||
onBlockedPopupLinkClick: () => void
|
||||
onDisplayNextBlock: (
|
||||
answerContent?: InputSubmitContent,
|
||||
isRetry?: boolean
|
||||
) => void
|
||||
}
|
||||
const ChatChunks = ({
|
||||
displayChunk: { bubbles, input },
|
||||
hostAvatar,
|
||||
hasGuestAvatar,
|
||||
keepShowingHostAvatar,
|
||||
blockedPopupUrl,
|
||||
onBlockedPopupLinkClick,
|
||||
onDisplayNextBlock,
|
||||
}: Props) => {
|
||||
const [isSkipped, setIsSkipped] = useState(false)
|
||||
|
||||
const avatarSideContainerRef = useRef<{ refreshTopOffset: () => void }>()
|
||||
|
||||
useEffect(() => {
|
||||
refreshTopOffset()
|
||||
})
|
||||
|
||||
const skipInput = () => {
|
||||
onDisplayNextBlock()
|
||||
setIsSkipped(true)
|
||||
}
|
||||
|
||||
const refreshTopOffset = () =>
|
||||
avatarSideContainerRef.current?.refreshTopOffset()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
{hostAvatar.isEnabled && bubbles.length > 0 && (
|
||||
<AvatarSideContainer
|
||||
ref={avatarSideContainerRef}
|
||||
hostAvatarSrc={hostAvatar.src}
|
||||
keepShowing={
|
||||
(keepShowingHostAvatar || isDefined(input)) && !isSkipped
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex-1"
|
||||
style={{ marginRight: hasGuestAvatar ? '50px' : '0.5rem' }}
|
||||
>
|
||||
<TransitionGroup>
|
||||
{bubbles.map((block) => (
|
||||
<CSSTransition
|
||||
key={block.id}
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
>
|
||||
<HostBubble
|
||||
block={block}
|
||||
onTransitionEnd={() => {
|
||||
onDisplayNextBlock()
|
||||
refreshTopOffset()
|
||||
}}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
{!isSkipped && (
|
||||
<CSSTransition
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
in={isDefined(input)}
|
||||
>
|
||||
{input ? (
|
||||
<InputChatBlock
|
||||
block={input}
|
||||
onTransitionEnd={onDisplayNextBlock}
|
||||
onSkip={skipInput}
|
||||
hasAvatar={hostAvatar.isEnabled}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</CSSTransition>
|
||||
)}
|
||||
{blockedPopupUrl ? (
|
||||
<div className="flex justify-end">
|
||||
<PopupBlockedToast
|
||||
url={blockedPopupUrl}
|
||||
onLinkClick={onBlockedPopupLinkClick}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { ChatGroup } from './ChatGroup'
|
@ -1,169 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChatGroup } from './ChatGroup'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { useAnswers } from '../providers/AnswersProvider'
|
||||
import { Group, Edge, PublicTypebot, Theme, VariableWithValue } from 'models'
|
||||
import { byId, isDefined, isInputBlock, isNotDefined } from 'utils'
|
||||
import { animateScroll as scroll } from 'react-scroll'
|
||||
import { LinkedTypebot, useTypebot } from '@/providers/TypebotProvider'
|
||||
import { setCssVariablesValue } from '@/features/theme'
|
||||
import { ChatProvider } from '@/providers/ChatProvider'
|
||||
|
||||
type Props = {
|
||||
theme: Theme
|
||||
predefinedVariables?: { [key: string]: string | undefined }
|
||||
startGroupId?: string
|
||||
onNewGroupVisible: (edge: Edge) => void
|
||||
onCompleted: () => void
|
||||
}
|
||||
export const ConversationContainer = ({
|
||||
theme,
|
||||
predefinedVariables,
|
||||
startGroupId,
|
||||
onNewGroupVisible,
|
||||
onCompleted,
|
||||
}: Props) => {
|
||||
const {
|
||||
typebot,
|
||||
updateVariableValue,
|
||||
linkedBotQueue,
|
||||
popEdgeIdFromLinkedTypebotQueue,
|
||||
} = useTypebot()
|
||||
const { document: frameDocument } = useFrame()
|
||||
const [displayedGroups, setDisplayedGroups] = useState<
|
||||
{ group: Group; startBlockIndex: number }[]
|
||||
>([])
|
||||
const { updateVariables } = useAnswers()
|
||||
const bottomAnchor = useRef<HTMLDivElement | null>(null)
|
||||
const scrollableContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [hasStarted, setHasStarted] = useState(false)
|
||||
|
||||
const displayNextGroup = ({
|
||||
edgeId,
|
||||
updatedTypebot,
|
||||
groupId,
|
||||
}: {
|
||||
edgeId?: string
|
||||
groupId?: string
|
||||
updatedTypebot?: PublicTypebot | LinkedTypebot
|
||||
}) => {
|
||||
const currentTypebot = updatedTypebot ?? typebot
|
||||
if (groupId) {
|
||||
const nextGroup = currentTypebot.groups.find(byId(groupId))
|
||||
if (!nextGroup) return
|
||||
onNewGroupVisible({
|
||||
id: 'edgeId',
|
||||
from: { groupId: 'block', blockId: 'block' },
|
||||
to: { groupId },
|
||||
})
|
||||
return setDisplayedGroups([
|
||||
...displayedGroups,
|
||||
{ group: nextGroup, startBlockIndex: 0 },
|
||||
])
|
||||
}
|
||||
const nextEdge = currentTypebot.edges.find(byId(edgeId))
|
||||
if (!nextEdge) {
|
||||
if (linkedBotQueue.length > 0) {
|
||||
const nextEdgeId = linkedBotQueue[0].edgeId
|
||||
popEdgeIdFromLinkedTypebotQueue()
|
||||
displayNextGroup({ edgeId: nextEdgeId })
|
||||
}
|
||||
return onCompleted()
|
||||
}
|
||||
const nextGroup = currentTypebot.groups.find(byId(nextEdge.to.groupId))
|
||||
if (!nextGroup) return onCompleted()
|
||||
const startBlockIndex = nextEdge.to.blockId
|
||||
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
|
||||
: 0
|
||||
onNewGroupVisible(nextEdge)
|
||||
setDisplayedGroups([
|
||||
...displayedGroups,
|
||||
{
|
||||
group: nextGroup,
|
||||
startBlockIndex: startBlockIndex === -1 ? 0 : startBlockIndex,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStarted) return
|
||||
if (
|
||||
isDefined(predefinedVariables) &&
|
||||
Object.keys(predefinedVariables).length > 0
|
||||
) {
|
||||
const prefilledVariables = injectPredefinedVariables(predefinedVariables)
|
||||
updateVariables(prefilledVariables)
|
||||
}
|
||||
setHasStarted(true)
|
||||
const startEdge = typebot.groups[0].blocks[0].outgoingEdgeId
|
||||
if (!startEdge && !startGroupId) return
|
||||
displayNextGroup({
|
||||
edgeId: startGroupId ? undefined : startEdge,
|
||||
groupId: startGroupId,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [predefinedVariables])
|
||||
|
||||
const injectPredefinedVariables = (predefinedVariables: {
|
||||
[key: string]: string | undefined
|
||||
}) => {
|
||||
const prefilledVariables: VariableWithValue[] = []
|
||||
Object.keys(predefinedVariables).forEach((key) => {
|
||||
const matchingVariable = typebot.variables.find(
|
||||
(v) => v.name.toLowerCase() === key.toLowerCase()
|
||||
)
|
||||
if (!predefinedVariables || isNotDefined(matchingVariable)) return
|
||||
const value = predefinedVariables[key]
|
||||
if (!value) return
|
||||
updateVariableValue(matchingVariable?.id, value)
|
||||
prefilledVariables.push({ ...matchingVariable, value })
|
||||
})
|
||||
return prefilledVariables
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameDocument) return
|
||||
setCssVariablesValue(theme, frameDocument.body.style)
|
||||
}, [theme, frameDocument])
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
if (!scrollableContainer.current) return
|
||||
setTimeout(() => {
|
||||
scroll.scrollToBottom({
|
||||
duration: 500,
|
||||
container: scrollableContainer.current,
|
||||
})
|
||||
}, 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollableContainer}
|
||||
className="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view"
|
||||
>
|
||||
<ChatProvider onScroll={autoScrollToBottom}>
|
||||
{displayedGroups.map((displayedGroup, idx) => {
|
||||
const groupAfter = displayedGroups[idx + 1]
|
||||
const groupAfterStartsWithInput =
|
||||
groupAfter &&
|
||||
isInputBlock(groupAfter.group.blocks[groupAfter.startBlockIndex])
|
||||
return (
|
||||
<ChatGroup
|
||||
key={displayedGroup.group.id + idx}
|
||||
blocks={displayedGroup.group.blocks}
|
||||
startBlockIndex={displayedGroup.startBlockIndex}
|
||||
onGroupEnd={displayNextGroup}
|
||||
groupTitle={displayedGroup.group.title}
|
||||
keepShowingHostAvatar={
|
||||
idx === displayedGroups.length - 1 || groupAfterStartsWithInput
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ChatProvider>
|
||||
|
||||
{/* We use a block to simulate padding because it makes iOS scroll flicker */}
|
||||
<div className="w-full h-32" ref={bottomAnchor} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
|
||||
export const LiteBadge = () => {
|
||||
const { document } = useFrame()
|
||||
const liteBadge = useRef<HTMLAnchorElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
const container = document.querySelector(
|
||||
'[data-testid="container"]'
|
||||
) as HTMLDivElement
|
||||
const observer = new MutationObserver(function (mutations_list) {
|
||||
mutations_list.forEach(function (mutation) {
|
||||
mutation.removedNodes.forEach(function (removed_node) {
|
||||
if ((removed_node as HTMLElement).id == 'lite-badge')
|
||||
container.append(liteBadge.current as Node)
|
||||
})
|
||||
})
|
||||
})
|
||||
observer.observe(container, {
|
||||
subtree: false,
|
||||
childList: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={liteBadge}
|
||||
href={'https://www.typebot.io/?utm_source=litebadge'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
|
||||
style={{ bottom: '20px' }}
|
||||
id="lite-badge"
|
||||
>
|
||||
Made with <span className="text-blue-500">Typebot</span>.
|
||||
</a>
|
||||
)
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
type Props = {
|
||||
url: string
|
||||
onLinkClick: () => void
|
||||
}
|
||||
|
||||
export const PopupBlockedToast = ({ url, onLinkClick }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span className="mb-1 text-sm font-semibold text-gray-900">
|
||||
Popup blocked
|
||||
</span>
|
||||
<div className="mb-2 text-sm font-normal">
|
||||
The bot wants to open a new tab but it was blocked by your broswer. It
|
||||
needs a manual approval.
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="py-1 px-4 justify-center text-sm 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"
|
||||
rel="noreferrer"
|
||||
onClick={onLinkClick}
|
||||
>
|
||||
Continue in new tab
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
import React, { SVGProps } from 'react'
|
||||
import { SendIcon } from './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"
|
||||
data-testid="loading-spinner"
|
||||
>
|
||||
<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>
|
||||
)
|
@ -1,133 +0,0 @@
|
||||
import { CSSProperties, useMemo } from 'react'
|
||||
import { TypebotProvider } from '../providers/TypebotProvider'
|
||||
import Frame from 'react-frame-component'
|
||||
import styles from '../assets/style.css'
|
||||
import importantStyles from '../assets/importantStyles.css'
|
||||
import phoneSyle from '../assets/phone.css'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import { AnswersProvider } from '../providers/AnswersProvider'
|
||||
import {
|
||||
AnswerInput,
|
||||
BackgroundType,
|
||||
Edge,
|
||||
PublicTypebot,
|
||||
VariableWithValue,
|
||||
} from 'models'
|
||||
import { Log } from 'db'
|
||||
import { LiteBadge } from './LiteBadge'
|
||||
import { getViewerUrl, isEmpty, isNotEmpty } from 'utils'
|
||||
|
||||
export type TypebotViewerProps = {
|
||||
typebot: Omit<PublicTypebot, 'updatedAt' | 'createdAt'>
|
||||
isPreview?: boolean
|
||||
apiHost?: string
|
||||
style?: CSSProperties
|
||||
predefinedVariables?: { [key: string]: string | undefined }
|
||||
resultId?: string
|
||||
startGroupId?: string
|
||||
isLoading?: boolean
|
||||
onNewGroupVisible?: (edge: Edge) => void
|
||||
onNewAnswer?: (
|
||||
answer: AnswerInput & { uploadedFiles: boolean }
|
||||
) => Promise<void>
|
||||
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
onCompleted?: () => void
|
||||
onVariablesUpdated?: (variables: VariableWithValue[]) => void
|
||||
}
|
||||
|
||||
export const TypebotViewer = ({
|
||||
typebot,
|
||||
apiHost = getViewerUrl(),
|
||||
isPreview = false,
|
||||
isLoading = false,
|
||||
style,
|
||||
resultId,
|
||||
startGroupId,
|
||||
predefinedVariables,
|
||||
onNewLog,
|
||||
onNewGroupVisible,
|
||||
onNewAnswer,
|
||||
onCompleted,
|
||||
onVariablesUpdated,
|
||||
}: TypebotViewerProps) => {
|
||||
const containerBgColor = useMemo(
|
||||
() =>
|
||||
typebot?.theme?.general?.background?.type === BackgroundType.COLOR
|
||||
? typebot.theme.general.background.content
|
||||
: 'transparent',
|
||||
[typebot?.theme?.general?.background]
|
||||
)
|
||||
const handleNewGroupVisible = (edge: Edge) =>
|
||||
onNewGroupVisible && onNewGroupVisible(edge)
|
||||
|
||||
const handleNewAnswer = (answer: AnswerInput & { uploadedFiles: boolean }) =>
|
||||
onNewAnswer && onNewAnswer(answer)
|
||||
|
||||
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
|
||||
onNewLog && onNewLog(log)
|
||||
|
||||
const handleCompleted = () => onCompleted && onCompleted()
|
||||
|
||||
if (isEmpty(apiHost))
|
||||
return <p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
|
||||
return (
|
||||
<Frame
|
||||
id="typebot-iframe"
|
||||
head={
|
||||
<>
|
||||
<style>
|
||||
{phoneSyle}
|
||||
{styles}
|
||||
</style>
|
||||
<style>{typebot.theme?.customCss}</style>
|
||||
<style>{importantStyles}</style>
|
||||
</>
|
||||
}
|
||||
name="Typebot viewer"
|
||||
style={{ width: '100%', height: '100%', border: 'none', ...style }}
|
||||
>
|
||||
{isNotEmpty(typebot?.theme?.general?.font) && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@import url('https://fonts.googleapis.com/css2?family=${
|
||||
typebot.theme.general.font ?? 'Open Sans'
|
||||
}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap');`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TypebotProvider
|
||||
typebot={typebot}
|
||||
apiHost={apiHost}
|
||||
isPreview={isPreview}
|
||||
onNewLog={handleNewLog}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<AnswersProvider
|
||||
resultId={resultId}
|
||||
onNewAnswer={handleNewAnswer}
|
||||
onVariablesUpdated={onVariablesUpdated}
|
||||
>
|
||||
<div
|
||||
className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container"
|
||||
style={{
|
||||
// We set this as inline style to avoid color flash for SSR
|
||||
backgroundColor: containerBgColor ?? 'transparent',
|
||||
}}
|
||||
data-testid="container"
|
||||
>
|
||||
<div className="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
theme={typebot.theme}
|
||||
onNewGroupVisible={handleNewGroupVisible}
|
||||
onCompleted={handleCompleted}
|
||||
predefinedVariables={predefinedVariables}
|
||||
startGroupId={startGroupId}
|
||||
/>
|
||||
</div>
|
||||
{typebot.settings.general.isBrandingEnabled && <LiteBadge />}
|
||||
</div>
|
||||
</AnswersProvider>
|
||||
</TypebotProvider>
|
||||
</Frame>
|
||||
)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export const TypingBubble = (): 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>
|
||||
)
|
@ -1,24 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const Avatar = ({ avatarSrc }: { avatarSrc?: string }): JSX.Element => {
|
||||
const [currentAvatarSrc] = useState(avatarSrc)
|
||||
|
||||
if (currentAvatarSrc === '') return <></>
|
||||
if (isDefined(currentAvatarSrc))
|
||||
return (
|
||||
<figure
|
||||
className={
|
||||
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-10 xs:h-10 xs:text-xl'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={currentAvatarSrc}
|
||||
alt="Bot avatar"
|
||||
className="rounded-full object-cover w-full h-full"
|
||||
/>
|
||||
</figure>
|
||||
)
|
||||
return <DefaultAvatar />
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export const DefaultAvatar = (): JSX.Element => {
|
||||
return (
|
||||
<figure
|
||||
className={
|
||||
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-10 xs:h-10 xs:text-xl'
|
||||
}
|
||||
data-testid="default-avatar"
|
||||
>
|
||||
<svg
|
||||
width="75"
|
||||
height="75"
|
||||
viewBox="0 0 75 75"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={
|
||||
'absolute top-0 left-0 w-6 h-6 xs:w-full xs:h-full xs:text-xl'
|
||||
}
|
||||
>
|
||||
<mask id="mask0" x="0" y="0" mask-type="alpha">
|
||||
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
|
||||
<rect
|
||||
x="2.50413"
|
||||
y="120.333"
|
||||
width="81.5597"
|
||||
height="86.4577"
|
||||
rx="2.5"
|
||||
transform="rotate(-52.6423 2.50413 120.333)"
|
||||
stroke="#FED23D"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
<circle cx="76.5" cy="-1.5" r="29" stroke="#FF8E20" strokeWidth="5" />
|
||||
<path
|
||||
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
|
||||
stroke="#F7F8FF"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
declare module '*.css'
|
@ -1,13 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export const SendIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width="19px"
|
||||
color="white"
|
||||
{...props}
|
||||
>
|
||||
<path d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z" />
|
||||
</svg>
|
||||
)
|
@ -1,23 +0,0 @@
|
||||
import { isMobile } from '@/utils/helpers'
|
||||
import React from 'react'
|
||||
|
||||
type ShortTextInputProps = {
|
||||
onChange: (value: string) => void
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>
|
||||
|
||||
export const ShortTextInput = React.forwardRef(function ShortTextInput(
|
||||
{ onChange, ...props }: ShortTextInputProps,
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
type="text"
|
||||
style={{ fontSize: '16px' }}
|
||||
autoFocus={!isMobile}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
@ -1,25 +0,0 @@
|
||||
import { isMobile } from '@/utils/helpers'
|
||||
import React from 'react'
|
||||
|
||||
type TextareaProps = {
|
||||
onChange: (value: string) => void
|
||||
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'>
|
||||
|
||||
export const Textarea = React.forwardRef(function Textarea(
|
||||
{ onChange, ...props }: TextareaProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
return (
|
||||
<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' }}
|
||||
autoFocus={!isMobile}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
@ -1,64 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { AudioBubbleContent } from 'models'
|
||||
import { TypingBubble } from '@/components/TypingBubble'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
|
||||
type Props = {
|
||||
url: AudioBubbleContent['url']
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
const showAnimationDuration = 400
|
||||
const typingDuration = 500
|
||||
|
||||
export const AudioBubble = ({ url, onTransitionEnd }: Props) => {
|
||||
const { typebot, isLoading } = useTypebot()
|
||||
const audio = useRef<HTMLAudioElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
const [parsedUrl] = useState(parseVariables(typebot.variables)(url))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping || isLoading) return
|
||||
|
||||
const typingTimeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, typingDuration)
|
||||
|
||||
return () => {
|
||||
clearTimeout(typingTimeout)
|
||||
}
|
||||
}, [isLoading, isTyping, onTransitionEnd])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<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 ? <TypingBubble /> : null}
|
||||
</div>
|
||||
<audio
|
||||
ref={audio}
|
||||
src={parsedUrl}
|
||||
className={
|
||||
'z-10 content-opacity m-2 ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{ height: isTyping ? '2rem' : 'revert' }}
|
||||
autoPlay
|
||||
controls
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './AudioBubble'
|
@ -1 +0,0 @@
|
||||
export * from './components'
|
@ -1,78 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { EmbedBubbleBlock } from 'models'
|
||||
import { TypingBubble } from '../../../../../components/TypingBubble'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
|
||||
type Props = {
|
||||
block: EmbedBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot, isLoading } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
const [url] = useState(parseVariables(typebot.variables)(block.content?.url))
|
||||
|
||||
const onTypingEnd = useCallback(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, [onTransitionEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping || isLoading) return
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [isLoading, isTyping, onTypingEnd])
|
||||
|
||||
const height = block.content.height
|
||||
? typeof block.content.height === 'string'
|
||||
? parseVariables(typebot.variables)(block.content.height) + 'px'
|
||||
: block.content.height
|
||||
: '2rem'
|
||||
|
||||
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 ? <TypingBubble /> : <></>}
|
||||
</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' : height,
|
||||
borderRadius: '15px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { EmbedBubble } from './components/EmbedBubble'
|
@ -1,86 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { ImageBubbleBlock } from 'models'
|
||||
import { TypingBubble } from '@/components/TypingBubble'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
|
||||
type Props = {
|
||||
block: ImageBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot, isLoading } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const image = useRef<HTMLImageElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
const [url] = useState(parseVariables(typebot.variables)(block.content?.url))
|
||||
|
||||
const onTypingEnd = useCallback(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, [onTransitionEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping || isLoading) return
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, mediaLoadingFallbackTimeout)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [isLoading, isTyping, onTypingEnd])
|
||||
|
||||
useEffect(() => {
|
||||
const currentImage = image.current
|
||||
if (!currentImage || isLoading || !isTyping) return
|
||||
currentImage.onload = () => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}
|
||||
return () => {
|
||||
currentImage.onload = null
|
||||
}
|
||||
}, [isLoading, isTyping, onTypingEnd])
|
||||
|
||||
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 ? <TypingBubble /> : null}
|
||||
</div>
|
||||
<img
|
||||
ref={image}
|
||||
src={url}
|
||||
className={
|
||||
'p-4 content-opacity z-10 w-auto rounded-lg ' +
|
||||
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
style={{
|
||||
maxHeight: '32rem',
|
||||
height: isTyping ? '2rem' : 'auto',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
alt="Bubble image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { ImageBubble } from './components/ImageBubble'
|
@ -1,91 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { BubbleBlockType, TextBubbleBlock } from 'models'
|
||||
import { computeTypingDuration } from '../utils/computeTypingDuration'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { TypingBubble } from '@/components/TypingBubble'
|
||||
|
||||
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, isLoading } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const [content] = useState(
|
||||
parseVariables(typebot.variables)(block.content.html)
|
||||
)
|
||||
|
||||
const onTypingEnd = useCallback(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, [onTransitionEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping || isLoading) return
|
||||
|
||||
const typingTimeout = computeTypingDuration(
|
||||
block.content.plainText,
|
||||
typebot.settings?.typingEmulation ?? defaultTypingEmulation
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
onTypingEnd()
|
||||
}, typingTimeout)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [
|
||||
block.content.plainText,
|
||||
isLoading,
|
||||
isTyping,
|
||||
onTypingEnd,
|
||||
typebot.settings?.typingEmulation,
|
||||
])
|
||||
|
||||
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 ? <TypingBubble /> : null}
|
||||
</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>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { TextBubble } from './components/TextBubble'
|
@ -1,16 +0,0 @@
|
||||
import { TypingEmulation } from 'models'
|
||||
|
||||
export const computeTypingDuration = (
|
||||
bubbleContent: string,
|
||||
typingSettings: TypingEmulation
|
||||
) => {
|
||||
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
|
||||
if (wordCount === 0) wordCount = bubbleContent.length
|
||||
const typedWordsPerMinute = typingSettings.speed
|
||||
let typingTimeout = typingSettings.enabled
|
||||
? (wordCount / typedWordsPerMinute) * 60000
|
||||
: 0
|
||||
if (typingTimeout > typingSettings.maxDelay * 1000)
|
||||
typingTimeout = typingSettings.maxDelay * 1000
|
||||
return typingTimeout
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import {
|
||||
Variable,
|
||||
VideoBubbleContent,
|
||||
VideoBubbleContentType,
|
||||
VideoBubbleBlock,
|
||||
} from 'models'
|
||||
import { TypingBubble } from '@/components/TypingBubble'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
|
||||
type Props = {
|
||||
block: VideoBubbleBlock
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
|
||||
const { typebot, isLoading } = useTypebot()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const onTypingEnd = useCallback(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}, [onTransitionEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping || isLoading) return
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
onTypingEnd()
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [isLoading, isTyping, onTypingEnd])
|
||||
|
||||
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 ? <TypingBubble /> : <></>}
|
||||
</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] = useState(parseVariables(variables)(content?.url))
|
||||
|
||||
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
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { VideoBubble } from './components/VideoBubble'
|
@ -1,94 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { useAnswers } from '@/providers/AnswersProvider'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { ChoiceInputBlock } from 'models'
|
||||
import React, { useState } from 'react'
|
||||
import { SendButton } from '../../../../../components/SendButton'
|
||||
|
||||
type ChoiceFormProps = {
|
||||
block: ChoiceInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
|
||||
const {
|
||||
typebot: { variables },
|
||||
} = useTypebot()
|
||||
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({
|
||||
value: parseVariables(variables)(block.items[itemIndex].content),
|
||||
itemId: block.items[itemIndex].id,
|
||||
})
|
||||
}
|
||||
|
||||
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({
|
||||
value: selectedIndices
|
||||
.map((itemIndex) =>
|
||||
parseVariables(variables)(block.items[itemIndex].content)
|
||||
)
|
||||
.join(', '),
|
||||
})
|
||||
|
||||
const isUniqueFirstButton =
|
||||
resultValues &&
|
||||
resultValues.answers.length === 0 &&
|
||||
block.items.length === 1
|
||||
|
||||
return (
|
||||
<form className="flex flex-col items-end" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-wrap justify-end">
|
||||
{block.items.map((item, idx) => (
|
||||
<span key={item.id} className="relative inline-flex ml-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}
|
||||
>
|
||||
{parseVariables(variables)(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>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { ChoiceForm } from './components/ChoiceForm'
|
@ -1,86 +0,0 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { DateInputOptions } from 'models'
|
||||
import { useState } from 'react'
|
||||
import { parseReadableDate } from '../utils/parseReadableDate'
|
||||
|
||||
type DateInputProps = {
|
||||
onSubmit: (inputValue: InputSubmitContent) => 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({
|
||||
value: `${inputValues.from}${
|
||||
isRange ? ` to ${inputValues.to}` : ''
|
||||
}`,
|
||||
label: parseReadableDate({ ...inputValues, hasTime, isRange }),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { DateForm } from './components/DateForm'
|
||||
export { parseReadableDate } from './utils/parseReadableDate'
|
@ -1,27 +0,0 @@
|
||||
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}` : ''}`
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { EmailInputBlock } from 'models'
|
||||
import React, { MutableRefObject, useRef, useState } from 'react'
|
||||
|
||||
type EmailInputProps = {
|
||||
block: EmailInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const EmailInput = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: EmailInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const handleChange = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue !== '' && inputRef.current?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef as MutableRefObject<HTMLInputElement>}
|
||||
value={inputValue}
|
||||
placeholder={block.options?.labels?.placeholder ?? 'Type your email...'}
|
||||
onChange={handleChange}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
onClick={submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { EmailInput } from './components/EmailInput'
|
||||
export { validateEmail } from './utils/validateEmail'
|
@ -1,4 +0,0 @@
|
||||
const emailRegex =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
|
||||
export const validateEmail = (email: string) => emailRegex.test(email)
|
@ -1,256 +0,0 @@
|
||||
import { Spinner, SendButton } from '@/components/SendButton'
|
||||
import { useAnswers } from '@/providers/AnswersProvider'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { defaultFileInputOptions, FileInputBlock } from 'models'
|
||||
import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react'
|
||||
import { uploadFiles } from 'utils'
|
||||
|
||||
type Props = {
|
||||
block: FileInputBlock
|
||||
onSubmit: (url: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const FileUploadForm = ({
|
||||
block: {
|
||||
id,
|
||||
options: { isMultipleAllowed, labels, sizeLimit, isRequired },
|
||||
},
|
||||
onSubmit,
|
||||
onSkip,
|
||||
}: Props) => {
|
||||
const { isPreview, currentTypebotId } = useTypebot()
|
||||
const { resultId } = useAnswers()
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string>()
|
||||
|
||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return
|
||||
onNewFiles(e.target.files)
|
||||
}
|
||||
|
||||
const onNewFiles = (files: FileList) => {
|
||||
setErrorMessage(undefined)
|
||||
const newFiles = Array.from(files)
|
||||
if (newFiles.some((file) => file.size > (sizeLimit ?? 10) * 1024 * 1024))
|
||||
return setErrorMessage(`A file is larger than ${sizeLimit ?? 10}MB`)
|
||||
if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0])
|
||||
setSelectedFiles([...selectedFiles, ...newFiles])
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedFiles.length === 0) return
|
||||
startFilesUpload(selectedFiles)
|
||||
}
|
||||
|
||||
const startSingleFileUpload = async (file: File) => {
|
||||
if (isPreview)
|
||||
return onSubmit({
|
||||
label: `File uploaded`,
|
||||
value: 'http://fake-upload-url.com',
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
basePath: `/api/typebots/${currentTypebotId}/blocks/${id}`,
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
path: `public/results/${resultId}/${id}/${file.name}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
setIsUploading(false)
|
||||
if (urls.length)
|
||||
return onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
|
||||
setErrorMessage('An error occured while uploading the file')
|
||||
}
|
||||
const startFilesUpload = async (files: File[]) => {
|
||||
if (isPreview)
|
||||
return 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/${currentTypebotId}/blocks/${id}`,
|
||||
files: files.map((file) => ({
|
||||
file: file,
|
||||
path: `public/results/${resultId}/${id}/${file.name}`,
|
||||
})),
|
||||
onUploadProgress: setUploadProgressPercent,
|
||||
})
|
||||
setIsUploading(false)
|
||||
setUploadProgressPercent(0)
|
||||
if (urls.length !== files.length)
|
||||
return setErrorMessage('An error occured while uploading the files')
|
||||
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<HTMLLabelElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!e.dataTransfer.files) return
|
||||
onNewFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const clearFiles = () => setSelectedFiles([])
|
||||
|
||||
return (
|
||||
<form className="flex flex-col w-full" onSubmit={handleSubmit}>
|
||||
<label
|
||||
htmlFor="dropzone-file"
|
||||
className={
|
||||
'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}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
{selectedFiles.length === 1 ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
{selectedFiles.length ? (
|
||||
<span className="relative">
|
||||
<FileIcon />
|
||||
<div
|
||||
className="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 h-4"
|
||||
style={{ bottom: '5px' }}
|
||||
>
|
||||
{selectedFiles.length}
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<UploadIcon />
|
||||
)}
|
||||
<p
|
||||
className="text-sm text-gray-500 text-center"
|
||||
dangerouslySetInnerHTML={{ __html: labels.placeholder }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple={isMultipleAllowed}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
{selectedFiles.length === 0 && isRequired === false && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
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 '
|
||||
}
|
||||
onClick={onSkip}
|
||||
>
|
||||
{labels.skip ?? defaultFileInputOptions.labels.skip}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMultipleAllowed && selectedFiles.length > 0 && !isUploading && (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex">
|
||||
{selectedFiles.length && (
|
||||
<button
|
||||
className={
|
||||
'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}
|
||||
>
|
||||
{labels.clear ?? defaultFileInputOptions.labels.clear}
|
||||
</button>
|
||||
)}
|
||||
<SendButton
|
||||
type="submit"
|
||||
label={
|
||||
labels.button === defaultFileInputOptions.labels.button
|
||||
? `${labels.button} ${selectedFiles.length} file${
|
||||
selectedFiles.length > 1 ? 's' : ''
|
||||
}`
|
||||
: labels.button
|
||||
}
|
||||
disableIcon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const UploadIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mb-3"
|
||||
>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
<line x1="12" y1="12" x2="12" y2="21"></line>
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FileIcon = () => (
|
||||
<svg
|
||||
className="mb-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
)
|
@ -1 +0,0 @@
|
||||
export { FileUploadForm } from './components/FileUploadForm'
|
@ -1,70 +0,0 @@
|
||||
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { NumberInputBlock } from 'models'
|
||||
import React, { MutableRefObject, useRef, useState } from 'react'
|
||||
|
||||
type NumberInputProps = {
|
||||
block: NumberInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const NumberInput = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: NumberInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const handleChange = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue !== '' && inputRef.current?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef as MutableRefObject<HTMLInputElement>}
|
||||
value={inputValue}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
style={{ appearance: 'auto' }}
|
||||
min={block.options?.min}
|
||||
max={block.options?.max}
|
||||
step={block.options?.step ?? 'any'}
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
onClick={submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { NumberInput } from './components/NumberInput'
|
@ -1,15 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
import React, { FormEvent, useEffect, useState } from 'react'
|
||||
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { PaymentInputOptions, Variable } from 'models'
|
||||
import { SendButton, Spinner } from '@/components/SendButton'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { initStripe } from '@/lib/stripe'
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { useChat } from '@/providers/ChatProvider'
|
||||
import { useTypebot } from '@/providers/TypebotProvider'
|
||||
import { createPaymentIntentQuery } from '../../queries/createPaymentIntentQuery'
|
||||
import { Stripe } from '@stripe/stripe-js'
|
||||
|
||||
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()
|
||||
const [stripe, setStripe] = useState<Stripe | null>(null)
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [amountLabel, setAmountLabel] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const { data, error } = await createPaymentIntentQuery({
|
||||
apiHost,
|
||||
isPreview,
|
||||
variables,
|
||||
inputOptions: options,
|
||||
})
|
||||
if (error)
|
||||
return onNewLog({
|
||||
status: 'error',
|
||||
description: error.name + ' ' + error.message,
|
||||
details: error.message,
|
||||
})
|
||||
if (!data || !frameDocument) return
|
||||
await initStripe(frameDocument)
|
||||
if (!frameWindow?.Stripe) return
|
||||
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, paymentIntent } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
// TO-DO: Handle redirection correctly.
|
||||
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?.type === 'validation_error') return
|
||||
if (error?.type === 'card_error') return setMessage(error.message)
|
||||
if (!error && paymentIntent.status === 'succeeded') return onSuccess()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { PaymentForm } from './PaymentForm'
|
@ -1 +0,0 @@
|
||||
export { PaymentForm } from './components/PaymentForm/'
|
@ -1,21 +0,0 @@
|
||||
import { PaymentInputOptions, Variable } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const createPaymentIntentQuery = ({
|
||||
apiHost,
|
||||
isPreview,
|
||||
inputOptions,
|
||||
variables,
|
||||
}: {
|
||||
inputOptions: PaymentInputOptions
|
||||
apiHost: string
|
||||
variables: Variable[]
|
||||
isPreview: boolean
|
||||
}) =>
|
||||
sendRequest<{ clientSecret: string; publicKey: string; amountLabel: string }>(
|
||||
{
|
||||
url: `${apiHost}/api/integrations/stripe/createPaymentIntent`,
|
||||
method: 'POST',
|
||||
body: { inputOptions, isPreview, variables },
|
||||
}
|
||||
)
|
@ -1,68 +0,0 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/helpers'
|
||||
import { PhoneNumberInputBlock } from 'models'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import ReactPhoneNumberInput, { Value, Country } from 'react-phone-number-input'
|
||||
|
||||
type PhoneInputProps = {
|
||||
block: PhoneNumberInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const PhoneInput = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: PhoneInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
const handleChange = (inputValue: Value | undefined) =>
|
||||
setInputValue(inputValue as string)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue !== '' && inputRef.current?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ReactPhoneNumberInput
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={block.options.labels.placeholder ?? 'Your phone number...'}
|
||||
defaultCountry={block.options.defaultCountryCode as Country}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
onClick={submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { PhoneInput } from './components/PhoneInput'
|
||||
export { validatePhoneNumber } from './utils/validatePhoneNumber'
|
@ -1,4 +0,0 @@
|
||||
import { isPossiblePhoneNumber } from 'react-phone-number-input'
|
||||
|
||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
||||
isPossiblePhoneNumber(phoneNumber)
|
@ -1,111 +0,0 @@
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { RatingInputOptions, RatingInputBlock } from 'models'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
import { isDefined, isEmpty, isNotDefined } from 'utils'
|
||||
import { SendButton } from '../../../../../components/SendButton'
|
||||
|
||||
type Props = {
|
||||
block: RatingInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const RatingForm = ({ block, onSubmit }: Props) => {
|
||||
const [rating, setRating] = useState<number>()
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isNotDefined(rating)) return
|
||||
onSubmit({ value: rating.toString() })
|
||||
}
|
||||
|
||||
const handleClick = (rating: number) => {
|
||||
if (block.options.isOneClickSubmitEnabled)
|
||||
onSubmit({ value: rating.toString() })
|
||||
setRating(rating)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit}>
|
||||
{block.options.labels.left && (
|
||||
<span className="text-sm w-full mb-2 rating-label">
|
||||
{block.options.labels.left}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center">
|
||||
{Array.from(
|
||||
Array(
|
||||
block.options.length +
|
||||
(block.options.buttonType === 'Numbers' ? 1 : 0)
|
||||
)
|
||||
).map((_, idx) => (
|
||||
<RatingButton
|
||||
{...block.options}
|
||||
key={idx}
|
||||
rating={rating}
|
||||
idx={idx + (block.options.buttonType === 'Numbers' ? 0 : 1)}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{block.options.labels.right && (
|
||||
<span className="text-sm w-full text-right mb-2 pr-2 rating-label">
|
||||
{block.options.labels.right}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<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>`
|
@ -1 +0,0 @@
|
||||
export { RatingForm } from './components/RatingForm'
|
@ -1,80 +0,0 @@
|
||||
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
|
||||
import { Textarea } from '@/components/inputs/Textarea'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { TextInputBlock } from 'models'
|
||||
import React, { MutableRefObject, useRef, useState } from 'react'
|
||||
|
||||
type TextInputProps = {
|
||||
block: TextInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
defaultValue: string | undefined
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const TextInput = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: TextInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const isLongText = block.options?.isLong
|
||||
|
||||
const handleChange = (inputValue: string) => setInputValue(inputValue)
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue !== '' && inputRef.current?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: React.KeyboardEvent) => {
|
||||
if (isLongText) return
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: isLongText ? undefined : '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
{isLongText ? (
|
||||
<Textarea
|
||||
ref={inputRef as MutableRefObject<HTMLTextAreaElement>}
|
||||
onChange={handleChange}
|
||||
value={inputValue}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ShortTextInput
|
||||
ref={inputRef as MutableRefObject<HTMLInputElement>}
|
||||
onChange={handleChange}
|
||||
value={inputValue}
|
||||
placeholder={
|
||||
block.options?.labels?.placeholder ?? 'Type your answer...'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SendButton
|
||||
type="button"
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
onClick={submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { TextInput } from './components/TextInput'
|
@ -1,71 +0,0 @@
|
||||
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { UrlInputBlock } from 'models'
|
||||
import React, { MutableRefObject, useRef, useState } from 'react'
|
||||
|
||||
type UrlInputProps = {
|
||||
block: UrlInputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}
|
||||
|
||||
export const UrlInput = ({
|
||||
block,
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: UrlInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const handleChange = (inputValue: string) => {
|
||||
if (!inputValue.startsWith('https://'))
|
||||
return inputValue === 'https:/'
|
||||
? undefined
|
||||
: setInputValue(`https://${inputValue}`)
|
||||
setInputValue(inputValue)
|
||||
}
|
||||
|
||||
const checkIfInputIsValid = () =>
|
||||
inputValue !== '' && inputRef.current?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
|
||||
}
|
||||
data-testid="input"
|
||||
style={{
|
||||
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
|
||||
maxWidth: '350px',
|
||||
}}
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef as MutableRefObject<HTMLInputElement>}
|
||||
value={inputValue}
|
||||
placeholder={block.options?.labels?.placeholder ?? 'Type your URL...'}
|
||||
onChange={handleChange}
|
||||
type="url"
|
||||
autoComplete="url"
|
||||
/>
|
||||
<SendButton
|
||||
type="button"
|
||||
label={block.options?.labels?.button ?? 'Send'}
|
||||
isDisabled={inputValue === ''}
|
||||
className="my-2 ml-2"
|
||||
onClick={submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { UrlInput } from './components/UrlInput'
|
||||
export { validateUrl } from './utils/validateUrl'
|
@ -1,4 +0,0 @@
|
||||
const urlRegex =
|
||||
/^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/
|
||||
|
||||
export const validateUrl = (url: string) => urlRegex.test(url)
|
@ -1 +0,0 @@
|
||||
export * from './utils/executeChatwootBlock'
|
@ -1,81 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { IntegrationState } from '@/types'
|
||||
import { sendEventToParent } from '@/utils/chat'
|
||||
import { isEmbedded } from '@/utils/helpers'
|
||||
import { ChatwootBlock, ChatwootOptions } from 'models'
|
||||
|
||||
const parseSetUserCode = (user: ChatwootOptions['user']) => `
|
||||
window.$chatwoot.setUser("${user?.id ?? ''}", {
|
||||
email: ${user?.email ? `"${user.email}"` : 'undefined'},
|
||||
name: ${user?.name ? `"${user.name}"` : 'undefined'},
|
||||
avatar_url: ${user?.avatarUrl ? `"${user.avatarUrl}"` : 'undefined'},
|
||||
phone_number: ${user?.phoneNumber ? `"${user.phoneNumber}"` : 'undefined'},
|
||||
});
|
||||
|
||||
`
|
||||
const parseChatwootOpenCode = ({
|
||||
baseUrl,
|
||||
websiteToken,
|
||||
user,
|
||||
}: ChatwootOptions) => `
|
||||
if (window.$chatwoot) {
|
||||
if(${Boolean(user)}) {
|
||||
${parseSetUserCode(user)}
|
||||
}
|
||||
window.$chatwoot.toggle("open");
|
||||
} else {
|
||||
(function (d, t) {
|
||||
var BASE_URL = "${baseUrl}";
|
||||
var g = d.createElement(t),
|
||||
s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: "${websiteToken}",
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
window.addEventListener("chatwoot:ready", function () {
|
||||
if(${Boolean(user?.id || user?.email)}) {
|
||||
${parseSetUserCode(user)}
|
||||
}
|
||||
window.$chatwoot.toggle("open");
|
||||
});
|
||||
};
|
||||
})(document, "script");
|
||||
}`
|
||||
|
||||
export const executeChatwootBlock = (
|
||||
block: ChatwootBlock,
|
||||
{ variables, isPreview, onNewLog }: IntegrationState
|
||||
) => {
|
||||
if (isPreview) {
|
||||
onNewLog({
|
||||
status: 'info',
|
||||
description: "Chatwoot won't open in preview mode",
|
||||
details: null,
|
||||
})
|
||||
} else if (isEmbedded) {
|
||||
sendEventToParent({
|
||||
closeChatBubble: true,
|
||||
})
|
||||
sendEventToParent({
|
||||
codeToExecute: parseVariables(variables)(
|
||||
parseChatwootOpenCode(block.options)
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const func = Function(
|
||||
parseVariables(variables)(parseChatwootOpenCode(block.options))
|
||||
)
|
||||
try {
|
||||
func()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeGoogleAnalyticsBlock } from './utils/executeGoogleAnalyticsBlock'
|
@ -1,15 +0,0 @@
|
||||
import { parseVariablesInObject } from '@/features/variables'
|
||||
import { sendGaEvent } from '@/lib/gtag'
|
||||
import { IntegrationState } from '@/types'
|
||||
import { GoogleAnalyticsBlock } from 'models'
|
||||
|
||||
export const executeGoogleAnalyticsBlock = async (
|
||||
block: GoogleAnalyticsBlock,
|
||||
{ variables }: IntegrationState
|
||||
) => {
|
||||
if (!block.options?.trackingId) return block.outgoingEdgeId
|
||||
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
|
||||
await initGoogleAnalytics(block.options.trackingId)
|
||||
sendGaEvent(parseVariablesInObject(block.options, variables))
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeGoogleSheetBlock } from './utils/executeGoogleSheetBlock'
|
@ -1,175 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { IntegrationState } from '@/types'
|
||||
import { parseLog } from '@/utils/helpers'
|
||||
import {
|
||||
GoogleSheetsBlock,
|
||||
GoogleSheetsAction,
|
||||
GoogleSheetsInsertRowOptions,
|
||||
GoogleSheetsUpdateRowOptions,
|
||||
GoogleSheetsGetOptions,
|
||||
VariableWithValue,
|
||||
Cell,
|
||||
Variable,
|
||||
} from 'models'
|
||||
import { sendRequest, byId } from 'utils'
|
||||
|
||||
export const executeGoogleSheetBlock = async (
|
||||
block: GoogleSheetsBlock,
|
||||
context: IntegrationState
|
||||
) => {
|
||||
if (!('action' in block.options)) return block.outgoingEdgeId
|
||||
switch (block.options.action) {
|
||||
case GoogleSheetsAction.INSERT_ROW:
|
||||
insertRowInGoogleSheets(block.options, context)
|
||||
break
|
||||
case GoogleSheetsAction.UPDATE_ROW:
|
||||
updateRowInGoogleSheets(block.options, context)
|
||||
break
|
||||
case GoogleSheetsAction.GET:
|
||||
await getRowFromGoogleSheets(block.options, context)
|
||||
break
|
||||
}
|
||||
return block.outgoingEdgeId
|
||||
}
|
||||
|
||||
const insertRowInGoogleSheets = (
|
||||
options: GoogleSheetsInsertRowOptions,
|
||||
{ variables, apiHost, onNewLog, resultId }: IntegrationState
|
||||
) => {
|
||||
if (!options.cellsToInsert) {
|
||||
onNewLog({
|
||||
status: 'warning',
|
||||
description: 'Cells to insert are undefined',
|
||||
details: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
sendRequest({
|
||||
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: GoogleSheetsAction.INSERT_ROW,
|
||||
credentialsId: options.credentialsId,
|
||||
resultId,
|
||||
values: parseCellValues(options.cellsToInsert, variables),
|
||||
},
|
||||
}).then(({ error }) => {
|
||||
onNewLog(
|
||||
parseLog(
|
||||
error,
|
||||
'Succesfully inserted a row in the sheet',
|
||||
'Failed to insert a row in the sheet'
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const updateRowInGoogleSheets = (
|
||||
options: GoogleSheetsUpdateRowOptions,
|
||||
{ variables, apiHost, onNewLog, resultId }: IntegrationState
|
||||
) => {
|
||||
if (!options.cellsToUpsert || !options.referenceCell) return
|
||||
sendRequest({
|
||||
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: GoogleSheetsAction.UPDATE_ROW,
|
||||
credentialsId: options.credentialsId,
|
||||
values: parseCellValues(options.cellsToUpsert, variables),
|
||||
resultId,
|
||||
referenceCell: {
|
||||
column: options.referenceCell.column,
|
||||
value: parseVariables(variables)(options.referenceCell.value ?? ''),
|
||||
},
|
||||
},
|
||||
}).then(({ error }) => {
|
||||
onNewLog(
|
||||
parseLog(
|
||||
error,
|
||||
'Succesfully updated a row in the sheet',
|
||||
'Failed to update a row in the sheet'
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const getRowFromGoogleSheets = async (
|
||||
options: GoogleSheetsGetOptions,
|
||||
{
|
||||
variables,
|
||||
updateVariableValue,
|
||||
updateVariables,
|
||||
apiHost,
|
||||
onNewLog,
|
||||
resultId,
|
||||
}: IntegrationState
|
||||
) => {
|
||||
if (!options.cellsToExtract) return
|
||||
const { data, error } = await sendRequest<{
|
||||
rows: { [key: string]: string }[]
|
||||
}>({
|
||||
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: GoogleSheetsAction.GET,
|
||||
credentialsId: options.credentialsId,
|
||||
referenceCell: options.referenceCell
|
||||
? {
|
||||
column: options.referenceCell.column,
|
||||
value: parseVariables(variables)(options.referenceCell.value ?? ''),
|
||||
}
|
||||
: undefined,
|
||||
filter: options.filter
|
||||
? {
|
||||
comparisons: options.filter.comparisons.map((comparison) => ({
|
||||
...comparison,
|
||||
value: parseVariables(variables)(comparison.value),
|
||||
})),
|
||||
logicalOperator: options.filter?.logicalOperator ?? 'AND',
|
||||
}
|
||||
: undefined,
|
||||
columns: options.cellsToExtract.map((cell) => cell.column),
|
||||
resultId,
|
||||
},
|
||||
})
|
||||
onNewLog(
|
||||
parseLog(
|
||||
error,
|
||||
'Succesfully fetched data from sheet',
|
||||
'Failed to fetch data from sheet'
|
||||
)
|
||||
)
|
||||
if (!data) return
|
||||
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
|
||||
(newVariables, cell) => {
|
||||
const existingVariable = variables.find(byId(cell.variableId))
|
||||
const rows = data.rows
|
||||
const randomRow = rows[Math.floor(Math.random() * rows.length)]
|
||||
const value = randomRow[cell.column ?? ''] ?? null
|
||||
if (!existingVariable) return newVariables
|
||||
updateVariableValue(existingVariable.id, value)
|
||||
return [
|
||||
...newVariables,
|
||||
{
|
||||
...existingVariable,
|
||||
value,
|
||||
},
|
||||
]
|
||||
},
|
||||
[]
|
||||
)
|
||||
updateVariables(newVariables)
|
||||
}
|
||||
|
||||
const parseCellValues = (
|
||||
cells: Cell[],
|
||||
variables: Variable[]
|
||||
): { [key: string]: string } =>
|
||||
cells.reduce((row, cell) => {
|
||||
return !cell.column || !cell.value
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
[cell.column]: parseVariables(variables)(cell.value),
|
||||
}
|
||||
}, {})
|
@ -1 +0,0 @@
|
||||
export { executeSendEmailBlock } from './utils/executeSendEmailBlock'
|
@ -1,53 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { IntegrationState } from '@/types'
|
||||
import { parseLog } from '@/utils/helpers'
|
||||
import { SendEmailBlock } from 'models'
|
||||
import { sendRequest, byId } from 'utils'
|
||||
|
||||
export const executeSendEmailBlock = (
|
||||
block: SendEmailBlock,
|
||||
{
|
||||
variables,
|
||||
apiHost,
|
||||
isPreview,
|
||||
onNewLog,
|
||||
resultId,
|
||||
typebotId,
|
||||
resultValues,
|
||||
}: IntegrationState
|
||||
) => {
|
||||
if (isPreview) {
|
||||
onNewLog({
|
||||
status: 'info',
|
||||
description: 'Emails are not sent in preview mode',
|
||||
details: null,
|
||||
})
|
||||
return block.outgoingEdgeId
|
||||
}
|
||||
const { options } = block
|
||||
sendRequest({
|
||||
url: `${apiHost}/api/typebots/${typebotId}/integrations/email?resultId=${resultId}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
credentialsId: options.credentialsId,
|
||||
recipients: options.recipients.map(parseVariables(variables)),
|
||||
subject: parseVariables(variables)(options.subject ?? ''),
|
||||
body: parseVariables(variables)(options.body ?? ''),
|
||||
cc: (options.cc ?? []).map(parseVariables(variables)),
|
||||
bcc: (options.bcc ?? []).map(parseVariables(variables)),
|
||||
replyTo: options.replyTo
|
||||
? parseVariables(variables)(options.replyTo)
|
||||
: undefined,
|
||||
fileUrls: variables.find(byId(options.attachmentsVariableId))?.value,
|
||||
isCustomBody: options.isCustomBody,
|
||||
isBodyCode: options.isBodyCode,
|
||||
resultValues,
|
||||
},
|
||||
}).then(({ error }) => {
|
||||
onNewLog(
|
||||
parseLog(error, 'Succesfully sent an email', 'Failed to send an email')
|
||||
)
|
||||
})
|
||||
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeWebhook } from './utils/executeWebhookBlock'
|
@ -1,71 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { IntegrationState } from '@/types'
|
||||
import {
|
||||
WebhookBlock,
|
||||
ZapierBlock,
|
||||
MakeComBlock,
|
||||
PabblyConnectBlock,
|
||||
VariableWithUnknowValue,
|
||||
} from 'models'
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest, byId } from 'utils'
|
||||
|
||||
export const executeWebhook = async (
|
||||
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock,
|
||||
{
|
||||
blockId,
|
||||
variables,
|
||||
updateVariableValue,
|
||||
updateVariables,
|
||||
typebotId,
|
||||
apiHost,
|
||||
resultValues,
|
||||
onNewLog,
|
||||
resultId,
|
||||
parentTypebotIds,
|
||||
}: IntegrationState
|
||||
) => {
|
||||
const params = stringify({ resultId })
|
||||
const { data, error } = await sendRequest({
|
||||
url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook?${params}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
variables,
|
||||
resultValues,
|
||||
parentTypebotIds,
|
||||
},
|
||||
})
|
||||
const statusCode = (
|
||||
data as Record<string, string> | undefined
|
||||
)?.statusCode.toString()
|
||||
const isError = statusCode
|
||||
? statusCode?.startsWith('4') || statusCode?.startsWith('5')
|
||||
: true
|
||||
onNewLog({
|
||||
status: error ? 'error' : isError ? 'warning' : 'success',
|
||||
description: isError
|
||||
? 'Webhook returned an error'
|
||||
: 'Webhook successfuly executed',
|
||||
details: JSON.stringify(error ?? data, null, 2).substring(0, 1000),
|
||||
})
|
||||
const newVariables = block.options.responseVariableMapping.reduce<
|
||||
VariableWithUnknowValue[]
|
||||
>((newVariables, varMapping) => {
|
||||
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
|
||||
const existingVariable = variables.find(byId(varMapping.variableId))
|
||||
if (!existingVariable) return newVariables
|
||||
const func = Function(
|
||||
'data',
|
||||
`return data.${parseVariables(variables)(varMapping?.bodyPath)}`
|
||||
)
|
||||
try {
|
||||
const value: unknown = func(data)
|
||||
updateVariableValue(existingVariable?.id, value)
|
||||
return [...newVariables, { ...existingVariable, value }]
|
||||
} catch (err) {
|
||||
return newVariables
|
||||
}
|
||||
}, [])
|
||||
updateVariables(newVariables)
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './utils'
|
@ -1,65 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { EdgeId, LogicState } from '@/types'
|
||||
import {
|
||||
Comparison,
|
||||
ComparisonOperators,
|
||||
ConditionBlock,
|
||||
LogicalOperator,
|
||||
Variable,
|
||||
} from 'models'
|
||||
import { isNotDefined, isDefined } from 'utils'
|
||||
|
||||
export const executeCondition = (
|
||||
block: ConditionBlock,
|
||||
{ typebot: { variables } }: LogicState
|
||||
): EdgeId | undefined => {
|
||||
const passedCondition = block.items.find((item) => {
|
||||
const { content } = item
|
||||
const isConditionPassed =
|
||||
content.logicalOperator === LogicalOperator.AND
|
||||
? content.comparisons.every(executeComparison(variables))
|
||||
: content.comparisons.some(executeComparison(variables))
|
||||
return isConditionPassed
|
||||
})
|
||||
return passedCondition ? passedCondition.outgoingEdgeId : block.outgoingEdgeId
|
||||
}
|
||||
|
||||
const executeComparison =
|
||||
(variables: Variable[]) => (comparison: Comparison) => {
|
||||
if (!comparison?.variableId) return false
|
||||
const inputValue = (
|
||||
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
const value = parseVariables(variables)(comparison.value).trim()
|
||||
if (isNotDefined(value) || !comparison.comparisonOperator) return false
|
||||
return matchComparison(inputValue, comparison.comparisonOperator, value)
|
||||
}
|
||||
|
||||
const matchComparison = (
|
||||
inputValue: string,
|
||||
comparisonOperator: ComparisonOperators,
|
||||
value: string
|
||||
) => {
|
||||
switch (comparisonOperator) {
|
||||
case ComparisonOperators.CONTAINS: {
|
||||
return inputValue.toLowerCase().includes(value.toLowerCase())
|
||||
}
|
||||
case ComparisonOperators.EQUAL: {
|
||||
return inputValue === value
|
||||
}
|
||||
case ComparisonOperators.NOT_EQUAL: {
|
||||
return inputValue !== value
|
||||
}
|
||||
case ComparisonOperators.GREATER: {
|
||||
return parseFloat(inputValue) > parseFloat(value)
|
||||
}
|
||||
case ComparisonOperators.LESS: {
|
||||
return parseFloat(inputValue) < parseFloat(value)
|
||||
}
|
||||
case ComparisonOperators.IS_SET: {
|
||||
return isDefined(inputValue) && inputValue.length > 0
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './executeCondition'
|
@ -1 +0,0 @@
|
||||
export { executeRedirect } from './utils/executeRedirect'
|
@ -1,39 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { EdgeId, LogicState } from '@/types'
|
||||
import { sendEventToParent } from '@/utils/chat'
|
||||
import { RedirectBlock } from 'models'
|
||||
import { sanitizeUrl } from 'utils'
|
||||
|
||||
export const executeRedirect = (
|
||||
block: RedirectBlock,
|
||||
{ typebot: { variables } }: LogicState
|
||||
): {
|
||||
nextEdgeId?: EdgeId
|
||||
blockedPopupUrl?: string
|
||||
} => {
|
||||
if (!block.options?.url) return { nextEdgeId: block.outgoingEdgeId }
|
||||
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
|
||||
const isEmbedded = window.parent && window.location !== window.top?.location
|
||||
let newWindow: Window | null = null
|
||||
if (isEmbedded) {
|
||||
if (!block.options.isNewTab) {
|
||||
;(window.top as Window).location.href = formattedUrl
|
||||
return { nextEdgeId: block.outgoingEdgeId }
|
||||
}
|
||||
|
||||
try {
|
||||
newWindow = window.open(formattedUrl)
|
||||
} catch (err) {
|
||||
sendEventToParent({ redirectUrl: formattedUrl })
|
||||
}
|
||||
} else {
|
||||
newWindow = window.open(
|
||||
formattedUrl,
|
||||
block.options.isNewTab ? '_blank' : '_self'
|
||||
)
|
||||
}
|
||||
return {
|
||||
nextEdgeId: block.outgoingEdgeId,
|
||||
blockedPopupUrl: newWindow ? undefined : formattedUrl,
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import { parseVariables, parseCorrectValueType } from '@/features/variables'
|
||||
import { LogicState } from '@/types'
|
||||
import { sendEventToParent } from '@/utils/chat'
|
||||
import { isEmbedded } from '@/utils/helpers'
|
||||
import { ScriptBlock } from 'models'
|
||||
|
||||
export const executeScript = async (
|
||||
block: ScriptBlock,
|
||||
{ typebot: { variables } }: LogicState
|
||||
) => {
|
||||
if (!block.options.content) return
|
||||
if (block.options.shouldExecuteInParentContext && isEmbedded) {
|
||||
sendEventToParent({
|
||||
codeToExecute: parseVariables(variables)(block.options.content),
|
||||
})
|
||||
} else {
|
||||
const func = Function(
|
||||
...variables.map((v) => v.id),
|
||||
parseVariables(variables, { fieldToParse: 'id' })(block.options.content)
|
||||
)
|
||||
try {
|
||||
await func(...variables.map((v) => parseCorrectValueType(v.value)))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeSetVariable } from './utils/executeSetVariable'
|
@ -1,35 +0,0 @@
|
||||
import { SetVariableBlock, Variable } from 'models'
|
||||
import { byId } from 'utils'
|
||||
import { EdgeId, LogicState } from '@/types'
|
||||
import { parseVariables, parseCorrectValueType } from '@/features/variables'
|
||||
|
||||
export const executeSetVariable = (
|
||||
block: SetVariableBlock,
|
||||
{ typebot: { variables }, updateVariableValue, updateVariables }: LogicState
|
||||
): EdgeId | undefined => {
|
||||
if (!block.options?.variableId) return block.outgoingEdgeId
|
||||
const evaluatedExpression = block.options.expressionToEvaluate
|
||||
? evaluateSetVariableExpression(variables)(
|
||||
block.options.expressionToEvaluate
|
||||
)
|
||||
: undefined
|
||||
const existingVariable = variables.find(byId(block.options.variableId))
|
||||
if (!existingVariable) return block.outgoingEdgeId
|
||||
updateVariableValue(existingVariable.id, evaluatedExpression)
|
||||
updateVariables([{ ...existingVariable, value: evaluatedExpression }])
|
||||
return block.outgoingEdgeId
|
||||
}
|
||||
|
||||
const evaluateSetVariableExpression =
|
||||
(variables: Variable[]) =>
|
||||
(str: string): unknown => {
|
||||
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
|
||||
str.includes('return ') ? str : `return ${str}`
|
||||
)
|
||||
try {
|
||||
const func = Function(...variables.map((v) => v.id), evaluating)
|
||||
return func(...variables.map((v) => parseCorrectValueType(v.value)))
|
||||
} catch (err) {
|
||||
return parseVariables(variables)(str)
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeTypebotLink } from './utils/executeTypebotLink'
|
@ -1,19 +0,0 @@
|
||||
import { LinkedTypebot } from '@/providers/TypebotProvider'
|
||||
import { LogicState } from '@/types'
|
||||
import { TypebotLinkBlock, Typebot, PublicTypebot } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const fetchAndInjectTypebot = async (
|
||||
block: TypebotLinkBlock,
|
||||
{ apiHost, injectLinkedTypebot, isPreview }: LogicState
|
||||
): Promise<LinkedTypebot | undefined> => {
|
||||
const { data, error } = isPreview
|
||||
? await sendRequest<{ typebot: Typebot }>(
|
||||
`/api/typebots/${block.options.typebotId}`
|
||||
)
|
||||
: await sendRequest<{ typebot: PublicTypebot }>(
|
||||
`${apiHost}/api/publicTypebots/${block.options.typebotId}`
|
||||
)
|
||||
if (!data || error) return
|
||||
return injectLinkedTypebot(data.typebot)
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
import { LinkedTypebot } from '@/providers/TypebotProvider'
|
||||
import { EdgeId, LogicState } from '@/types'
|
||||
import { TypebotLinkBlock, Edge, PublicTypebot } from 'models'
|
||||
import { fetchAndInjectTypebot } from '../queries/fetchAndInjectTypebotQuery'
|
||||
|
||||
export const executeTypebotLink = async (
|
||||
block: TypebotLinkBlock,
|
||||
context: LogicState
|
||||
): Promise<{
|
||||
nextEdgeId?: EdgeId
|
||||
linkedTypebot?: PublicTypebot | LinkedTypebot
|
||||
}> => {
|
||||
const {
|
||||
typebot,
|
||||
linkedTypebots,
|
||||
onNewLog,
|
||||
createEdge,
|
||||
setCurrentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
pushParentTypebotId,
|
||||
currentTypebotId,
|
||||
} = context
|
||||
const linkedTypebot = (
|
||||
block.options.typebotId === 'current'
|
||||
? typebot
|
||||
: [typebot, ...linkedTypebots].find((typebot) =>
|
||||
'typebotId' in typebot
|
||||
? typebot.typebotId === block.options.typebotId
|
||||
: typebot.id === block.options.typebotId
|
||||
) ?? (await fetchAndInjectTypebot(block, context))
|
||||
) as PublicTypebot | LinkedTypebot | undefined
|
||||
if (!linkedTypebot) {
|
||||
onNewLog({
|
||||
status: 'error',
|
||||
description: 'Failed to link typebot',
|
||||
details: '',
|
||||
})
|
||||
return { nextEdgeId: block.outgoingEdgeId }
|
||||
}
|
||||
if (block.outgoingEdgeId)
|
||||
pushEdgeIdInLinkedTypebotQueue({
|
||||
edgeId: block.outgoingEdgeId,
|
||||
typebotId: currentTypebotId,
|
||||
})
|
||||
pushParentTypebotId(currentTypebotId)
|
||||
setCurrentTypebotId(
|
||||
'typebotId' in linkedTypebot ? linkedTypebot.typebotId : linkedTypebot.id
|
||||
)
|
||||
const nextGroupId =
|
||||
block.options.groupId ??
|
||||
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
|
||||
?.id
|
||||
if (!nextGroupId) return { nextEdgeId: block.outgoingEdgeId }
|
||||
const newEdge: Edge = {
|
||||
id: (Math.random() * 1000).toString(),
|
||||
from: { blockId: '', groupId: '' },
|
||||
to: {
|
||||
groupId: nextGroupId,
|
||||
},
|
||||
}
|
||||
createEdge(newEdge)
|
||||
return {
|
||||
nextEdgeId: newEdge.id,
|
||||
linkedTypebot: {
|
||||
...linkedTypebot,
|
||||
edges: [...linkedTypebot.edges, newEdge],
|
||||
},
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { executeWait } from './utils/executeWait'
|
@ -1,19 +0,0 @@
|
||||
import { parseVariables } from '@/features/variables'
|
||||
import { LogicState } from '@/types'
|
||||
import { WaitBlock } from 'models'
|
||||
|
||||
export const executeWait = async (
|
||||
block: WaitBlock,
|
||||
{ typebot: { variables } }: LogicState
|
||||
) => {
|
||||
if (!block.options.secondsToWaitFor) return block.outgoingEdgeId
|
||||
const parsedSecondsToWaitFor = parseVariables(variables)(
|
||||
block.options.secondsToWaitFor
|
||||
)
|
||||
// @ts-expect-error isNaN can be used with strings
|
||||
if (isNaN(parsedSecondsToWaitFor)) return block.outgoingEdgeId
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, parseInt(parsedSecondsToWaitFor) * 1000)
|
||||
)
|
||||
return block.outgoingEdgeId
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './utils/setCssVariablesValue'
|
@ -1,142 +0,0 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundType,
|
||||
ChatTheme,
|
||||
ContainerColors,
|
||||
GeneralTheme,
|
||||
InputColors,
|
||||
Theme,
|
||||
} from 'models'
|
||||
|
||||
const cssVariableNames = {
|
||||
general: {
|
||||
bgImage: '--typebot-container-bg-image',
|
||||
bgColor: '--typebot-container-bg-color',
|
||||
fontFamily: '--typebot-container-font-family',
|
||||
},
|
||||
chat: {
|
||||
hostBubbles: {
|
||||
bgColor: '--typebot-host-bubble-bg-color',
|
||||
color: '--typebot-host-bubble-color',
|
||||
},
|
||||
guestBubbles: {
|
||||
bgColor: '--typebot-guest-bubble-bg-color',
|
||||
color: '--typebot-guest-bubble-color',
|
||||
},
|
||||
inputs: {
|
||||
bgColor: '--typebot-input-bg-color',
|
||||
color: '--typebot-input-color',
|
||||
placeholderColor: '--typebot-input-placeholder-color',
|
||||
},
|
||||
buttons: {
|
||||
bgColor: '--typebot-button-bg-color',
|
||||
color: '--typebot-button-color',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const setCssVariablesValue = (
|
||||
theme: Theme | undefined,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
if (!theme) return
|
||||
if (theme.general) setGeneralTheme(theme.general, documentStyle)
|
||||
if (theme.chat) setChatTheme(theme.chat, documentStyle)
|
||||
}
|
||||
|
||||
const setGeneralTheme = (
|
||||
generalTheme: GeneralTheme,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
const { background, font } = generalTheme
|
||||
if (background) setTypebotBackground
|
||||
if (font) documentStyle.setProperty(cssVariableNames.general.fontFamily, font)
|
||||
}
|
||||
|
||||
const setChatTheme = (
|
||||
chatTheme: ChatTheme,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
const { hostBubbles, guestBubbles, buttons, inputs } = chatTheme
|
||||
if (hostBubbles) setHostBubbles(hostBubbles, documentStyle)
|
||||
if (guestBubbles) setGuestBubbles(guestBubbles, documentStyle)
|
||||
if (buttons) setButtons(buttons, documentStyle)
|
||||
if (inputs) setInputs(inputs, documentStyle)
|
||||
}
|
||||
|
||||
const setHostBubbles = (
|
||||
hostBubbles: ContainerColors,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
if (hostBubbles.backgroundColor)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.hostBubbles.bgColor,
|
||||
hostBubbles.backgroundColor
|
||||
)
|
||||
if (hostBubbles.color)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.hostBubbles.color,
|
||||
hostBubbles.color
|
||||
)
|
||||
}
|
||||
|
||||
const setGuestBubbles = (
|
||||
guestBubbles: ContainerColors,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
if (guestBubbles.backgroundColor)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.guestBubbles.bgColor,
|
||||
guestBubbles.backgroundColor
|
||||
)
|
||||
if (guestBubbles.color)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.guestBubbles.color,
|
||||
guestBubbles.color
|
||||
)
|
||||
}
|
||||
|
||||
const setButtons = (
|
||||
buttons: ContainerColors,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
if (buttons.backgroundColor)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.buttons.bgColor,
|
||||
buttons.backgroundColor
|
||||
)
|
||||
if (buttons.color)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.buttons.color,
|
||||
buttons.color
|
||||
)
|
||||
}
|
||||
|
||||
const setInputs = (inputs: InputColors, documentStyle: CSSStyleDeclaration) => {
|
||||
if (inputs.backgroundColor)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.inputs.bgColor,
|
||||
inputs.backgroundColor
|
||||
)
|
||||
if (inputs.color)
|
||||
documentStyle.setProperty(cssVariableNames.chat.inputs.color, inputs.color)
|
||||
if (inputs.placeholderColor)
|
||||
documentStyle.setProperty(
|
||||
cssVariableNames.chat.inputs.placeholderColor,
|
||||
inputs.placeholderColor
|
||||
)
|
||||
}
|
||||
|
||||
const setTypebotBackground = (
|
||||
background: Background,
|
||||
documentStyle: CSSStyleDeclaration
|
||||
) => {
|
||||
documentStyle.setProperty(
|
||||
background?.type === BackgroundType.IMAGE
|
||||
? cssVariableNames.general.bgImage
|
||||
: cssVariableNames.general.bgColor,
|
||||
background.type === BackgroundType.NONE
|
||||
? 'transparent'
|
||||
: background.content ?? '#ffffff'
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './utils'
|
@ -1,86 +0,0 @@
|
||||
import { Variable, VariableWithValue } from 'models'
|
||||
import { isDefined, isNotDefined } from 'utils'
|
||||
|
||||
export const stringContainsVariable = (str: string): boolean =>
|
||||
/\{\{(.*?)\}\}/g.test(str)
|
||||
|
||||
export const parseVariables =
|
||||
(
|
||||
variables: Variable[],
|
||||
options: { fieldToParse?: 'value' | 'id'; escapeForJson?: boolean } = {
|
||||
fieldToParse: 'value',
|
||||
escapeForJson: false,
|
||||
}
|
||||
) =>
|
||||
(text: string | undefined): string => {
|
||||
if (!text || text === '') return ''
|
||||
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
|
||||
const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
|
||||
const variable = variables.find((variable) => {
|
||||
return (
|
||||
matchedVarName === variable.name &&
|
||||
(options.fieldToParse === 'id' || isDefined(variable.value))
|
||||
)
|
||||
}) as VariableWithValue | undefined
|
||||
if (!variable) return ''
|
||||
if (options.fieldToParse === 'id') return variable.id
|
||||
const { value } = variable
|
||||
if (options.escapeForJson)
|
||||
return typeof value === 'string'
|
||||
? jsonParse(value)
|
||||
: jsonParse(JSON.stringify(value))
|
||||
const parsedValue = safeStringify(value)
|
||||
if (!parsedValue) return ''
|
||||
return parsedValue
|
||||
})
|
||||
}
|
||||
|
||||
export const safeStringify = (val: unknown): string | null => {
|
||||
if (isNotDefined(val)) return null
|
||||
if (typeof val === 'string') return val
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
console.warn('Failed to safely stringify variable value', val)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const parseCorrectValueType = (
|
||||
value: Variable['value']
|
||||
): string | string[] | boolean | number | null | undefined => {
|
||||
if (value === null) return null
|
||||
if (value === undefined) return undefined
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === 'number') return value
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (value === 'null') return null
|
||||
if (value === 'undefined') return undefined
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const jsonParse = (str: string) =>
|
||||
str
|
||||
.replace(/\n/g, `\\n`)
|
||||
.replace(/"/g, `\\"`)
|
||||
.replace(/\\[^n"]/g, `\\\\ `)
|
||||
|
||||
export const parseVariablesInObject = (
|
||||
object: { [key: string]: string | number },
|
||||
variables: Variable[]
|
||||
) =>
|
||||
Object.keys(object).reduce((newObj, key) => {
|
||||
const currentValue = object[key]
|
||||
return {
|
||||
...newObj,
|
||||
[key]:
|
||||
typeof currentValue === 'string'
|
||||
? parseVariables(variables)(currentValue)
|
||||
: currentValue,
|
||||
}
|
||||
}, {})
|
@ -1,2 +0,0 @@
|
||||
export * from './components/TypebotViewer'
|
||||
export { parseVariables } from '@/features/variables'
|
@ -1,45 +0,0 @@
|
||||
import { GoogleAnalyticsOptions } from 'models'
|
||||
|
||||
declare const gtag: (
|
||||
type: string,
|
||||
action: string | undefined,
|
||||
options: {
|
||||
event_category: string | undefined
|
||||
event_label: string | undefined
|
||||
value: number | undefined
|
||||
}
|
||||
) => void
|
||||
|
||||
const initGoogleAnalytics = (id: string): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
const existingScript = document.getElementById('gtag')
|
||||
if (!existingScript) {
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`
|
||||
script.id = 'gtag'
|
||||
const initScript = document.createElement('script')
|
||||
initScript.innerHTML = `window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '${id}');
|
||||
`
|
||||
document.body.appendChild(script)
|
||||
document.body.appendChild(initScript)
|
||||
script.onload = () => {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
if (existingScript) resolve()
|
||||
})
|
||||
|
||||
export const sendGaEvent = (options: GoogleAnalyticsOptions) => {
|
||||
if (!options) return
|
||||
gtag('event', options.action, {
|
||||
event_category: options.category,
|
||||
event_label: options.label,
|
||||
value: options.value,
|
||||
})
|
||||
}
|
||||
|
||||
export default initGoogleAnalytics
|
@ -1,12 +0,0 @@
|
||||
export const initStripe = (document: Document): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
const existingScript = document.getElementById('stripe-script')
|
||||
if (existingScript) return resolve()
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://js.stripe.com/v3'
|
||||
script.id = 'stripe-script'
|
||||
document.body.appendChild(script)
|
||||
script.onload = () => {
|
||||
resolve()
|
||||
}
|
||||
})
|
@ -1,102 +0,0 @@
|
||||
import { safeStringify } from '@/features/variables'
|
||||
import {
|
||||
AnswerInput,
|
||||
ResultValuesInput,
|
||||
Variable,
|
||||
VariableWithUnknowValue,
|
||||
VariableWithValue,
|
||||
} from 'models'
|
||||
import { createContext, ReactNode, useContext, useState } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
|
||||
const answersContext = createContext<{
|
||||
resultId?: string
|
||||
resultValues: ResultValuesInput
|
||||
addAnswer: (
|
||||
existingVariables: Variable[]
|
||||
) => (
|
||||
answer: AnswerInput & { uploadedFiles: boolean }
|
||||
) => Promise<void> | undefined
|
||||
updateVariables: (variables: VariableWithUnknowValue[]) => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const AnswersProvider = ({
|
||||
children,
|
||||
resultId,
|
||||
onNewAnswer,
|
||||
onVariablesUpdated,
|
||||
}: {
|
||||
resultId?: string
|
||||
onNewAnswer: (
|
||||
answer: AnswerInput & { uploadedFiles: boolean }
|
||||
) => Promise<void> | undefined
|
||||
onVariablesUpdated?: (variables: VariableWithValue[]) => void
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [resultValues, setResultValues] = useState<ResultValuesInput>({
|
||||
answers: [],
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const addAnswer =
|
||||
(existingVariables: Variable[]) =>
|
||||
(answer: AnswerInput & { uploadedFiles: boolean }) => {
|
||||
if (answer.variableId)
|
||||
updateVariables([
|
||||
{
|
||||
id: answer.variableId,
|
||||
value: answer.content,
|
||||
name:
|
||||
existingVariables.find(
|
||||
(existingVariable) => existingVariable.id === answer.variableId
|
||||
)?.name ?? '',
|
||||
},
|
||||
])
|
||||
setResultValues((resultValues) => ({
|
||||
...resultValues,
|
||||
answers: [...resultValues.answers, answer],
|
||||
}))
|
||||
return onNewAnswer && onNewAnswer(answer)
|
||||
}
|
||||
|
||||
const updateVariables = (newVariables: VariableWithUnknowValue[]) => {
|
||||
const serializedNewVariables = newVariables.map((variable) => ({
|
||||
...variable,
|
||||
value: safeStringify(variable.value),
|
||||
}))
|
||||
|
||||
setResultValues((resultValues) => {
|
||||
const updatedVariables = [
|
||||
...resultValues.variables.filter((v) =>
|
||||
serializedNewVariables.every(
|
||||
(variable) => variable.id !== v.id || variable.name !== v.name
|
||||
)
|
||||
),
|
||||
...serializedNewVariables,
|
||||
].filter((variable) => isDefined(variable.value)) as VariableWithValue[]
|
||||
if (onVariablesUpdated) onVariablesUpdated(updatedVariables)
|
||||
return {
|
||||
...resultValues,
|
||||
variables: updatedVariables,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<answersContext.Provider
|
||||
value={{
|
||||
resultId,
|
||||
resultValues,
|
||||
addAnswer,
|
||||
updateVariables,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</answersContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAnswers = () => useContext(answersContext)
|
@ -1,28 +0,0 @@
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
|
||||
const chatContext = createContext<{
|
||||
scroll: () => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const ChatProvider = ({
|
||||
children,
|
||||
onScroll,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onScroll: () => void
|
||||
}) => {
|
||||
const scroll = onScroll
|
||||
return (
|
||||
<chatContext.Provider
|
||||
value={{
|
||||
scroll,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</chatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useChat = () => useContext(chatContext)
|
@ -1,197 +0,0 @@
|
||||
import { TypebotViewerProps } from '@/components/TypebotViewer'
|
||||
import { safeStringify } from '@/features/variables'
|
||||
import { sendEventToParent } from '@/utils/chat'
|
||||
import { Log } from 'db'
|
||||
import { Edge, PublicTypebot, Typebot, Variable } from 'models'
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
|
||||
export type LinkedTypebot = Pick<
|
||||
PublicTypebot | Typebot,
|
||||
'id' | 'groups' | 'variables' | 'edges'
|
||||
>
|
||||
|
||||
export type LinkedTypebotQueue = {
|
||||
typebotId: string
|
||||
edgeId: string
|
||||
}[]
|
||||
|
||||
const typebotContext = createContext<{
|
||||
currentTypebotId: string
|
||||
typebot: TypebotViewerProps['typebot']
|
||||
linkedTypebots: LinkedTypebot[]
|
||||
apiHost: string
|
||||
isPreview: boolean
|
||||
linkedBotQueue: LinkedTypebotQueue
|
||||
isLoading: boolean
|
||||
parentTypebotIds: string[]
|
||||
setCurrentTypebotId: (id: string) => void
|
||||
updateVariableValue: (variableId: string, value: unknown) => void
|
||||
createEdge: (edge: Edge) => void
|
||||
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
|
||||
pushParentTypebotId: (typebotId: string) => void
|
||||
popEdgeIdFromLinkedTypebotQueue: () => void
|
||||
pushEdgeIdInLinkedTypebotQueue: (bot: {
|
||||
typebotId: string
|
||||
edgeId: string
|
||||
}) => void
|
||||
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const TypebotProvider = ({
|
||||
children,
|
||||
typebot,
|
||||
apiHost,
|
||||
isPreview,
|
||||
isLoading,
|
||||
onNewLog,
|
||||
}: {
|
||||
children: ReactNode
|
||||
typebot: TypebotViewerProps['typebot']
|
||||
apiHost: string
|
||||
isLoading: boolean
|
||||
isPreview: boolean
|
||||
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
}) => {
|
||||
const [localTypebot, setLocalTypebot] =
|
||||
useState<TypebotViewerProps['typebot']>(typebot)
|
||||
const [linkedTypebots, setLinkedTypebots] = useState<LinkedTypebot[]>([])
|
||||
const [currentTypebotId, setCurrentTypebotId] = useState(typebot.typebotId)
|
||||
const [linkedBotQueue, setLinkedBotQueue] = useState<LinkedTypebotQueue>([])
|
||||
const [parentTypebotIds, setParentTypebotIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTypebot((localTypebot) => ({
|
||||
...localTypebot,
|
||||
theme: typebot.theme,
|
||||
settings: typebot.settings,
|
||||
}))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [typebot.theme, typebot.settings])
|
||||
|
||||
const updateVariableValue = (variableId: string, value: unknown) => {
|
||||
const formattedValue = safeStringify(value)
|
||||
|
||||
sendEventToParent({
|
||||
newVariableValue: {
|
||||
name:
|
||||
localTypebot.variables.find((variable) => variable.id === variableId)
|
||||
?.name ?? '',
|
||||
value: formattedValue ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const variable = localTypebot.variables.find((v) => v.id === variableId)
|
||||
const otherVariablesWithSameName = localTypebot.variables.filter(
|
||||
(v) => v.name === variable?.name && v.id !== variableId
|
||||
)
|
||||
const variablesToUpdate = [variable, ...otherVariablesWithSameName].filter(
|
||||
isDefined
|
||||
)
|
||||
|
||||
setLocalTypebot((typebot) => ({
|
||||
...typebot,
|
||||
variables: typebot.variables.map((variable) =>
|
||||
variablesToUpdate.some(
|
||||
(variableToUpdate) => variableToUpdate.id === variable.id
|
||||
)
|
||||
? { ...variable, value: formattedValue }
|
||||
: variable
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const createEdge = (edge: Edge) => {
|
||||
setLocalTypebot((typebot) => ({
|
||||
...typebot,
|
||||
edges: [...typebot.edges, edge],
|
||||
}))
|
||||
}
|
||||
|
||||
const injectLinkedTypebot = (typebot: Typebot | PublicTypebot) => {
|
||||
const newVariables = fillVariablesWithExistingValues(
|
||||
typebot.variables,
|
||||
localTypebot.variables
|
||||
)
|
||||
const typebotToInject = {
|
||||
id: 'typebotId' in typebot ? typebot.typebotId : typebot.id,
|
||||
groups: typebot.groups,
|
||||
edges: typebot.edges,
|
||||
variables: newVariables,
|
||||
}
|
||||
setLinkedTypebots((typebots) => [...typebots, typebotToInject])
|
||||
const updatedTypebot = {
|
||||
...localTypebot,
|
||||
groups: [...localTypebot.groups, ...typebotToInject.groups],
|
||||
variables: [...localTypebot.variables, ...typebotToInject.variables],
|
||||
edges: [...localTypebot.edges, ...typebotToInject.edges],
|
||||
}
|
||||
setLocalTypebot(updatedTypebot)
|
||||
return typebotToInject
|
||||
}
|
||||
|
||||
const fillVariablesWithExistingValues = (
|
||||
variables: Variable[],
|
||||
variablesWithValues: Variable[]
|
||||
): Variable[] =>
|
||||
variables.map((variable) => {
|
||||
const matchedVariable = variablesWithValues.find(
|
||||
(variableWithValue) => variableWithValue.name === variable.name
|
||||
)
|
||||
|
||||
return {
|
||||
...variable,
|
||||
value: matchedVariable?.value ?? variable.value,
|
||||
}
|
||||
})
|
||||
|
||||
const pushParentTypebotId = (typebotId: string) => {
|
||||
setParentTypebotIds((ids) => [...ids, typebotId])
|
||||
}
|
||||
|
||||
const pushEdgeIdInLinkedTypebotQueue = (bot: {
|
||||
typebotId: string
|
||||
edgeId: string
|
||||
}) => setLinkedBotQueue((queue) => [...queue, bot])
|
||||
|
||||
const popEdgeIdFromLinkedTypebotQueue = () => {
|
||||
setLinkedBotQueue((queue) => queue.slice(1))
|
||||
setParentTypebotIds((ids) => ids.slice(1))
|
||||
setCurrentTypebotId(linkedBotQueue[0].typebotId)
|
||||
}
|
||||
|
||||
return (
|
||||
<typebotContext.Provider
|
||||
value={{
|
||||
typebot: localTypebot,
|
||||
linkedTypebots,
|
||||
apiHost,
|
||||
isPreview,
|
||||
updateVariableValue,
|
||||
createEdge,
|
||||
injectLinkedTypebot,
|
||||
onNewLog,
|
||||
linkedBotQueue,
|
||||
isLoading,
|
||||
parentTypebotIds,
|
||||
pushParentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
popEdgeIdFromLinkedTypebotQueue,
|
||||
currentTypebotId,
|
||||
setCurrentTypebotId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</typebotContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTypebot = () => useContext(typebotContext)
|
@ -1,55 +0,0 @@
|
||||
import { Log } from 'db'
|
||||
import {
|
||||
Edge,
|
||||
Group,
|
||||
PublicTypebot,
|
||||
ResultValuesInput,
|
||||
Typebot,
|
||||
Variable,
|
||||
VariableWithUnknowValue,
|
||||
} from 'models'
|
||||
import { TypebotViewerProps } from './components/TypebotViewer'
|
||||
import { LinkedTypebot } from './providers/TypebotProvider'
|
||||
|
||||
export type InputSubmitContent = {
|
||||
label?: string
|
||||
value: string
|
||||
itemId?: string
|
||||
}
|
||||
|
||||
export type EdgeId = string
|
||||
|
||||
export type LogicState = {
|
||||
isPreview: boolean
|
||||
apiHost: string
|
||||
typebot: TypebotViewerProps['typebot']
|
||||
linkedTypebots: LinkedTypebot[]
|
||||
currentTypebotId: string
|
||||
pushParentTypebotId: (id: string) => void
|
||||
pushEdgeIdInLinkedTypebotQueue: (bot: {
|
||||
edgeId: string
|
||||
typebotId: string
|
||||
}) => void
|
||||
setCurrentTypebotId: (id: string) => void
|
||||
updateVariableValue: (variableId: string, value: unknown) => void
|
||||
updateVariables: (variables: VariableWithUnknowValue[]) => void
|
||||
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
|
||||
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
createEdge: (edge: Edge) => void
|
||||
}
|
||||
|
||||
export type IntegrationState = {
|
||||
apiHost: string
|
||||
typebotId: string
|
||||
groupId: string
|
||||
blockId: string
|
||||
isPreview: boolean
|
||||
variables: Variable[]
|
||||
resultValues: ResultValuesInput
|
||||
groups: Group[]
|
||||
resultId?: string
|
||||
parentTypebotIds: string[]
|
||||
updateVariables: (variables: VariableWithUnknowValue[]) => void
|
||||
updateVariableValue: (variableId: string, value: unknown) => void
|
||||
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import {
|
||||
BubbleBlock,
|
||||
BubbleBlockType,
|
||||
InputBlock,
|
||||
InputBlockType,
|
||||
Block,
|
||||
} from 'models'
|
||||
import { isBubbleBlock, isInputBlock } from 'utils'
|
||||
import type { TypebotPostMessageData } from 'typebot-js'
|
||||
|
||||
export const getLastChatBlockType = (
|
||||
blocks: Block[]
|
||||
): BubbleBlockType | InputBlockType | undefined => {
|
||||
const displayedBlocks = blocks.filter(
|
||||
(s) => isBubbleBlock(s) || isInputBlock(s)
|
||||
) as (BubbleBlock | InputBlock)[]
|
||||
return displayedBlocks.pop()?.type
|
||||
}
|
||||
|
||||
export const sendEventToParent = (data: TypebotPostMessageData) => {
|
||||
try {
|
||||
window.top?.postMessage(
|
||||
{
|
||||
from: 'typebot',
|
||||
...data,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { executeChatwootBlock } from '@/features/blocks/integrations/chatwoot'
|
||||
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics'
|
||||
import { executeGoogleSheetBlock } from '@/features/blocks/integrations/googleSheets'
|
||||
import { executeSendEmailBlock } from '@/features/blocks/integrations/sendEmail'
|
||||
import { executeWebhook } from '@/features/blocks/integrations/webhook'
|
||||
import { IntegrationState } from '@/types'
|
||||
import { IntegrationBlock, IntegrationBlockType } from 'models'
|
||||
|
||||
export const executeIntegration = ({
|
||||
block,
|
||||
context,
|
||||
}: {
|
||||
block: IntegrationBlock
|
||||
context: IntegrationState
|
||||
}): Promise<string | undefined> | string | undefined => {
|
||||
switch (block.type) {
|
||||
case IntegrationBlockType.GOOGLE_SHEETS:
|
||||
return executeGoogleSheetBlock(block, context)
|
||||
case IntegrationBlockType.GOOGLE_ANALYTICS:
|
||||
return executeGoogleAnalyticsBlock(block, context)
|
||||
case IntegrationBlockType.ZAPIER:
|
||||
case IntegrationBlockType.MAKE_COM:
|
||||
case IntegrationBlockType.PABBLY_CONNECT:
|
||||
case IntegrationBlockType.WEBHOOK:
|
||||
return executeWebhook(block, context)
|
||||
case IntegrationBlockType.EMAIL:
|
||||
return executeSendEmailBlock(block, context)
|
||||
case IntegrationBlockType.CHATWOOT:
|
||||
return executeChatwootBlock(block, context)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user