2
0

Add Bubble and Popup in embed lib v2

Closes #214
This commit is contained in:
Baptiste Arnaud
2023-01-09 14:51:36 +01:00
parent 4bf93b4872
commit 21f1c7a17e
38 changed files with 1586 additions and 96 deletions

View File

@ -28,7 +28,7 @@ export const AvatarSideContainer = (props: Props) => {
<div
ref={avatarContainer}
class={
'flex w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
'flex mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
(isMobile() ? 'w-6' : 'w-10')
}
>

View File

@ -42,7 +42,7 @@ export const ConversationContainer = (props: Props) => {
}
return (
<div class="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">
<div class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view">
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk

View File

@ -1,52 +1,124 @@
import styles from '../../../assets/index.css'
import { createSignal } from 'solid-js'
import { createSignal, onMount, Show, splitProps, onCleanup } from 'solid-js'
import { Bot, BotProps } from '../../../components/Bot'
import { CommandData } from '@/features/commands'
import { BubbleButton } from './BubbleButton'
import { PreviewMessage, PreviewMessageProps } from './PreviewMessage'
import { isDefined } from 'utils'
import { BubbleParams } from '../types'
export type BubbleProps = BotProps &
BubbleParams & {
onOpen?: () => void
onClose?: () => void
onPreviewMessageClick?: () => void
}
export const Bubble = (props: BubbleProps) => {
const [bubbleProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'previewMessage',
'onPreviewMessageClick',
'button',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isPreviewMessageDisplayed, setIsPreviewMessageDisplayed] =
createSignal(false)
const [previewMessage, setPreviewMessage] = createSignal<
Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
>({
message: bubbleProps.previewMessage?.message ?? '',
avatarUrl: bubbleProps.previewMessage?.avatarUrl,
})
export const Bubble = () => {
const [isBotOpened, setIsBotOpened] = createSignal(false)
const [isBotStarted, setIsBotStarted] = createSignal(false)
onMount(() => {
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = bubbleProps.previewMessage?.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
showMessage()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
})
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'showPreviewMessage') showMessage(data.message)
if (data.command === 'hidePreviewMessage') hideMessage()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
if (!isBotStarted()) setIsBotStarted(true)
hideMessage()
setIsBotOpened(true)
if (isBotOpened()) bubbleProps.onOpen?.()
}
const closeBot = () => {
setIsBotOpened(false)
if (isBotOpened()) bubbleProps.onClose?.()
}
const toggleBot = () => {
setIsBotOpened(!isBotOpened())
isBotOpened() ? closeBot() : openBot()
}
const handlePreviewMessageClick = () => {
bubbleProps.onPreviewMessageClick?.()
openBot()
}
const showMessage = (
previewMessage?: Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
) => {
if (previewMessage) setPreviewMessage(previewMessage)
if (isBotOpened()) return
setIsPreviewMessageDisplayed(true)
}
const hideMessage = () => {
setIsPreviewMessageDisplayed(false)
}
return (
<>
<style>{styles}</style>
<button
onClick={toggleBot}
class="bg-blue-500 text-red-300 absolute bottom-4 right-4 w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center"
>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 stroke-white stroke-2 fill-transparent absolute ' +
(isBotOpened() ? 'scale-0 opacity-0' : 'scale-100 opacity-100')
}
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 fill-white absolute ' +
(isBotOpened()
? 'scale-100 rotate-0 opacity-100'
: 'scale-0 -rotate-180 opacity-0')
}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.601 8.39897C18.269 8.06702 17.7309 8.06702 17.3989 8.39897L12 13.7979L6.60099 8.39897C6.26904 8.06702 5.73086 8.06702 5.39891 8.39897C5.06696 8.73091 5.06696 9.2691 5.39891 9.60105L11.3989 15.601C11.7309 15.933 12.269 15.933 12.601 15.601L18.601 9.60105C18.9329 9.2691 18.9329 8.73091 18.601 8.39897Z"
/>
</svg>
</button>
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
button={bubbleProps.button}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
<BubbleButton
{...bubbleProps.button}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
/>
<div
style={{
width: '400px',
height: 'calc(100% - 104px)',
'max-height': '704px',
height: 'calc(100% - 80px)',
transition:
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
'transform-origin': 'bottom right',
@ -54,10 +126,14 @@ export const Bubble = () => {
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
}}
class={
'absolute bottom-20 right-4 rounded-2xl ' +
'absolute bottom-20 sm:right-4 rounded-lg bg-white w-full sm:w-[400px] max-h-[704px] ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
/>
>
<Show when={isBotStarted()}>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
</Show>
</div>
</>
)
}

View File

@ -0,0 +1,67 @@
import { Show } from 'solid-js'
import { ButtonParams } from '../types'
type Props = ButtonParams & {
isBotOpened: boolean
toggleBot: () => void
}
const defaultButtonColor = '#0042DA'
export const BubbleButton = (props: Props) => {
return (
<button
// eslint-disable-next-line solid/reactivity
onClick={props.toggleBot}
class={
'absolute bottom-4 right-4 shadow-md w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center animate-fade-in'
}
style={{
'background-color': props.backgroundColor ?? defaultButtonColor,
}}
>
<Show when={props.icon?.color} keyed>
{(color) => (
<svg
viewBox="0 0 24 24"
style={{
stroke: color,
}}
class={
`w-7 stroke-2 fill-transparent absolute duration-200 transition ` +
(props.isBotOpened
? 'scale-0 opacity-0'
: 'scale-100 opacity-100')
}
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
)}
</Show>
<Show when={props.icon?.url}>
<img
src={props.icon?.url}
class="w-7 h-7 rounded-full object-cover"
alt="Bubble button icon"
/>
</Show>
<svg
viewBox="0 0 24 24"
style={{ fill: props.icon?.color ?? 'white' }}
class={
`w-7 absolute duration-200 transition ` +
(props.isBotOpened
? 'scale-100 rotate-0 opacity-100'
: 'scale-0 -rotate-180 opacity-0')
}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.601 8.39897C18.269 8.06702 17.7309 8.06702 17.3989 8.39897L12 13.7979L6.60099 8.39897C6.26904 8.06702 5.73086 8.06702 5.39891 8.39897C5.06696 8.73091 5.06696 9.2691 5.39891 9.60105L11.3989 15.601C11.7309 15.933 12.269 15.933 12.601 15.601L18.601 9.60105C18.9329 9.2691 18.9329 8.73091 18.601 8.39897Z"
/>
</svg>
</button>
)
}

View File

@ -0,0 +1,68 @@
import { createSignal } from 'solid-js'
import { BubbleParams, PreviewMessageParams } from '../types'
export type PreviewMessageProps = Pick<
PreviewMessageParams,
'avatarUrl' | 'message' | 'style'
> &
Pick<BubbleParams, 'button'> & {
onClick: () => void
onCloseClick: () => void
}
const defaultFontFamily =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"
export const PreviewMessage = (props: PreviewMessageProps) => {
const [isPreviewMessageHovered, setIsPreviewMessageHovered] =
createSignal(false)
return (
<div
// eslint-disable-next-line solid/reactivity
onClick={props.onClick}
class="absolute bottom-20 right-4 w-64 rounded-md duration-200 flex items-center gap-4 shadow-md animate-fade-in cursor-pointer hover:shadow-lg p-4"
style={{
'font-family': props.style?.fontFamily ?? defaultFontFamily,
'background-color': props.style?.backgroundColor ?? '#F7F8FF',
color: props.style?.color ?? '#303235',
}}
onMouseEnter={() => setIsPreviewMessageHovered(true)}
onMouseLeave={() => setIsPreviewMessageHovered(false)}
>
<button
class={
`absolute -top-3 -right-3 rounded-full w-6 h-6 p-1 hover:brightness-95 active:brightness-90 transition-all ` +
(isPreviewMessageHovered() ? 'opacity-100' : 'opacity-0')
}
onClick={(e) => {
e.stopPropagation()
return props.onCloseClick()
}}
style={{
'background-color': props.style?.closeButtonBgColor ?? '#F7F8FF',
color: props.style?.closeButtonColor ?? '#303235',
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<img
src={props.avatarUrl}
class="rounded-full w-8 h-8 object-cover"
alt="Bot avatar"
/>
<p>{props.message}</p>
</div>
)
}

View File

@ -0,0 +1,27 @@
export type BubbleParams = {
button: ButtonParams
previewMessage: PreviewMessageParams
}
export type ButtonParams = {
backgroundColor?: string
icon?: {
color?: string
url?: string
}
}
export type PreviewMessageParams = {
avatarUrl?: string
message: string
autoShowDelay?: number
style?: PreviewMessageStyle
}
type PreviewMessageStyle = Partial<{
backgroundColor: string
color: string
fontFamily: string
closeButtonBgColor: string
closeButtonColor: string
}>

View File

@ -0,0 +1,2 @@
export * from './types'
export * from './utils'

View File

@ -0,0 +1,21 @@
import { PreviewMessageParams } from '../bubble/types'
export type CommandData = {
isFromTypebot: boolean
} & (
| {
command: 'open' | 'toggle' | 'close' | 'hidePreviewMessage'
}
| ShowMessageCommandData
| SetPrefilledVariablesCommandData
)
export type ShowMessageCommandData = {
command: 'showPreviewMessage'
message?: Pick<PreviewMessageParams, 'avatarUrl' | 'message'>
}
export type SetPrefilledVariablesCommandData = {
command: 'setPrefilledVariables'
variables: Record<string, string | number | boolean>
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const close = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'close',
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const hidePreviewMessage = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'hidePreviewMessage',
}
window.postMessage(message)
}

View File

@ -0,0 +1,6 @@
export * from './close'
export * from './hidePreviewMessage'
export * from './open'
export * from './setPrefilledVariables'
export * from './showPreviewMessage'
export * from './toggle'

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const open = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'open',
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData } from '../types'
export const setPrefilledVariables = (
variables: Record<string, string | number | boolean>
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'setPrefilledVariables',
variables,
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData, ShowMessageCommandData } from '../types'
export const showPreviewMessage = (
proactiveMessage?: ShowMessageCommandData['message']
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'showPreviewMessage',
message: proactiveMessage,
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const toggle = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'toggle',
}
window.postMessage(message)
}

View File

@ -1,3 +1,106 @@
export const Popup = () => {
return <div />
import styles from '../../../assets/index.css'
import { createSignal, onMount, Show, splitProps, onCleanup } from 'solid-js'
import { Bot, BotProps } from '../../../components/Bot'
import { CommandData } from '@/features/commands'
import { isDefined } from 'utils'
import { PopupParams } from '../types'
export type PopupProps = BotProps &
PopupParams & {
onOpen?: () => void
onClose?: () => void
}
export const Popup = (props: PopupProps) => {
let botContainer: HTMLDivElement | undefined
const [popupProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'autoShowDelay',
'style',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isBotOpened, setIsBotOpened] = createSignal(false)
onMount(() => {
window.addEventListener('click', processWindowClick)
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = popupProps.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
openBot()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
window.removeEventListener('click', processWindowClick)
})
const processWindowClick = (event: MouseEvent) => {
if (!botContainer || botContainer.contains(event.target as Node)) return
setIsBotOpened(false)
}
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
setIsBotOpened(true)
if (isBotOpened()) popupProps.onOpen?.()
}
const closeBot = () => {
setIsBotOpened(false)
if (isBotOpened()) popupProps.onClose?.()
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
return (
<Show when={isBotOpened()}>
<div
class="relative z-10"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<style>{styles}</style>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity animate-fade-in" />
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
class="relative h-[80vh] transform overflow-hidden rounded-lg text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
style={{
width: popupProps.style?.width ?? '100%',
'background-color': popupProps.style?.backgroundColor ?? '#fff',
}}
ref={botContainer}
>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
</div>
</div>
</div>
</div>
</Show>
)
}

View File

@ -0,0 +1,7 @@
export type PopupParams = {
autoShowDelay?: number
style?: {
width?: string
backgroundColor?: string
}
}

View File

@ -1,2 +1,5 @@
export { registerWebComponents } from './register'
export * from './register'
export type { BotProps } from './components/Bot'
export type { BubbleProps } from './features/bubble'
export type { PopupProps } from './features/popup'
export * from './features/commands'

View File

@ -1,7 +1,19 @@
/* eslint-disable solid/reactivity */
import { customElement } from 'solid-element'
import { Bot, BotProps } from './components/Bot'
import { Bubble, BubbleProps } from './features/bubble'
import { Popup, PopupProps } from './features/popup'
export const registerWebComponents = (props: BotProps) => {
export const registerStandardComponent = (props: BotProps) => {
if (typeof window === 'undefined') return
customElement('typebot-standard', props, Bot)
}
export const registerBubbleComponent = (props: BubbleProps) => {
if (typeof window === 'undefined') return
customElement('typebot-bubble', props, Bubble)
}
export const registerPopupComponent = (props: PopupProps) => {
if (typeof window === 'undefined') return
customElement('typebot-popup', props, Popup)
}