2
0

Restore chat state when user is remembered (#1333)

Closes #993

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added a detailed explanation page for the "Remember user" setting in
the app documentation.
- Introduced persistence of chat state across sessions, with options for
local or session storage.
- Enhanced bot functionality to store and retrieve initial chat replies
and manage bot open state with improved storage handling.
- Added a new callback for chat state persistence to bot component
props.

- **Improvements**
- Updated the general settings form to clarify the description of the
"Remember user" feature.
- Enhanced custom CSS handling and progress value persistence in bot
components.
- Added conditional transition disabling in various components for
smoother user experiences.
- Simplified the handling of `onTransitionEnd` across multiple bubble
components.

- **Refactor**
- Renamed `inputIndex` to `chunkIndex` or `index` in various components
for consistency.
	- Removed unused ESLint disable comments related to reactivity rules.
	- Adjusted import statements and cleaned up code across several files.

- **Bug Fixes**
- Fixed potential issues with undefined callbacks by introducing
optional chaining in component props.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-03-07 15:39:09 +01:00
committed by GitHub
parent 583294f90c
commit 0dc276c18f
31 changed files with 427 additions and 154 deletions

View File

@ -6,7 +6,7 @@ type Props = {
}
export const findResult = ({ id }: Props) =>
prisma.result.findFirst({
where: { id },
where: { id, isArchived: { not: true } },
select: {
id: true,
variables: true,

View File

@ -6,5 +6,6 @@ module.exports = {
'@next/next/no-img-element': 'off',
'@next/next/no-html-link-for-pages': 'off',
'solid/no-innerhtml': 'off',
'solid/reactivity': 'off',
},
}

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.2.48",
"version": "0.2.49",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",

View File

@ -8,7 +8,10 @@ import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
import { ErrorMessage } from './ErrorMessage'
import {
getExistingResultIdFromStorage,
getInitialChatReplyFromStorage,
setInitialChatReplyInStorage,
setResultInStorage,
wipeExistingChatStateInStorage,
} from '@/utils/storage'
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
import immutableCss from '../assets/immutable.css'
@ -20,6 +23,8 @@ import { HTTPError } from 'ky'
import { injectFont } from '@/utils/injectFont'
import { ProgressBar } from './ProgressBar'
import { Portal } from 'solid-js/web'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
import { persist } from '@/utils/persist'
export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -35,6 +40,7 @@ export type BotProps = {
onInit?: () => void
onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void
onChatStatePersisted?: (isEnabled: boolean) => void
startFrom?: StartFrom
}
@ -59,14 +65,13 @@ export const Bot = (props: BotProps & { class?: string }) => {
typeof props.typebot === 'string' ? props.typebot : undefined
const isPreview =
typeof props.typebot !== 'string' || (props.isPreview ?? false)
const resultIdInStorage = getExistingResultIdFromStorage(typebotIdFromProps)
const { data, error } = await startChatQuery({
stripeRedirectStatus: urlParams.get('redirect_status') ?? undefined,
typebot: props.typebot,
apiHost: props.apiHost,
isPreview,
resultId: isNotEmpty(props.resultId)
? props.resultId
: getExistingResultIdFromStorage(typebotIdFromProps),
resultId: isNotEmpty(props.resultId) ? props.resultId : resultIdInStorage,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
@ -111,17 +116,40 @@ export const Bot = (props: BotProps & { class?: string }) => {
)
}
if (data.resultId && typebotIdFromProps)
setResultInStorage(data.typebot.settings.general?.rememberUser?.storage)(
typebotIdFromProps,
data.resultId
if (
data.resultId &&
typebotIdFromProps &&
(data.typebot.settings.general?.rememberUser?.isEnabled ??
defaultSettings.general.rememberUser.isEnabled)
) {
if (resultIdInStorage && resultIdInStorage !== data.resultId)
wipeExistingChatStateInStorage(data.typebot.id)
const storage =
data.typebot.settings.general?.rememberUser?.storage ??
defaultSettings.general.rememberUser.storage
setResultInStorage(storage)(typebotIdFromProps, data.resultId)
const initialChatInStorage = getInitialChatReplyFromStorage(
data.typebot.id
)
setInitialChatReply(data)
setCustomCss(data.typebot.theme.customCss ?? '')
if (initialChatInStorage) {
setInitialChatReply(initialChatInStorage)
} else {
setInitialChatReply(data)
setInitialChatReplyInStorage(data, {
typebotId: data.typebot.id,
storage,
})
}
props.onChatStatePersisted?.(true)
} else {
setInitialChatReply(data)
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock(data.input)
if (data.logs) props.onNewLogs?.(data.logs)
props.onChatStatePersisted?.(false)
}
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock(data.input)
if (data.logs) props.onNewLogs?.(data.logs)
setCustomCss(data.typebot.theme.customCss ?? '')
}
createEffect(() => {
@ -178,6 +206,16 @@ export const Bot = (props: BotProps & { class?: string }) => {
resultId: initialChatReply.resultId,
sessionId: initialChatReply.sessionId,
typebot: initialChatReply.typebot,
storage:
initialChatReply.typebot.settings.general?.rememberUser
?.isEnabled &&
!(
typeof props.typebot !== 'string' ||
(props.isPreview ?? false)
)
? initialChatReply.typebot.settings.general?.rememberUser
?.storage ?? defaultSettings.general.rememberUser.storage
: undefined,
}}
progressBarRef={props.progressBarRef}
onNewInputBlock={props.onNewInputBlock}
@ -203,8 +241,12 @@ type BotContentProps = {
}
const BotContent = (props: BotContentProps) => {
const [progressValue, setProgressValue] = createSignal<number | undefined>(
props.initialChatReply.progress
const [progressValue, setProgressValue] = persist(
createSignal<number | undefined>(props.initialChatReply.progress),
{
storage: props.context.storage,
key: `typebot-${props.context.typebot.id}-progressValue`,
}
)
let botContainer: HTMLDivElement | undefined

View File

@ -2,7 +2,11 @@ import { createSignal, onCleanup, onMount } from 'solid-js'
import { isMobile } from '@/utils/isMobileSignal'
import { Avatar } from '../avatars/Avatar'
type Props = { hostAvatarSrc?: string; hideAvatar?: boolean }
type Props = {
hostAvatarSrc?: string
hideAvatar?: boolean
isTransitionDisabled?: boolean
}
export const AvatarSideContainer = (props: Props) => {
let avatarContainer: HTMLDivElement | undefined
@ -40,7 +44,9 @@ export const AvatarSideContainer = (props: Props) => {
}
style={{
top: `${top()}px`,
transition: 'top 350ms ease-out, opacity 250ms ease-out',
transition: props.isTransitionDisabled
? undefined
: 'top 350ms ease-out, opacity 250ms ease-out',
}}
>
<Avatar initialAvatarSrc={props.hostAvatarSrc} />

View File

@ -12,11 +12,12 @@ import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/c
type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
theme: Theme
settings: Settings
inputIndex: number
index: number
context: BotContext
hasError: boolean
hideAvatar: boolean
streamingMessageId: ChatChunkType['streamingMessageId']
isTransitionDisabled?: boolean
onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: (top?: number) => void
onSubmit: (input?: string) => void
@ -26,7 +27,9 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
export const ChatChunk = (props: Props) => {
let inputRef: HTMLDivElement | undefined
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(
props.isTransitionDisabled ? props.messages.length : 0
)
const [lastBubbleOffsetTop, setLastBubbleOffsetTop] = createSignal<number>()
onMount(() => {
@ -45,7 +48,6 @@ export const ChatChunk = (props: Props) => {
defaultSettings.typingEmulation.delayBetweenBubbles) > 0 &&
displayedMessageIndex() < props.messages.length - 1
) {
// eslint-disable-next-line solid/reactivity
await new Promise((resolve) =>
setTimeout(
resolve,
@ -82,6 +84,7 @@ export const ChatChunk = (props: Props) => {
<AvatarSideContainer
hostAvatarSrc={props.theme.chat?.hostAvatar?.url}
hideAvatar={props.hideAvatar}
isTransitionDisabled={props.isTransitionDisabled}
/>
</Show>
@ -108,10 +111,12 @@ export const ChatChunk = (props: Props) => {
(props.settings.typingEmulation?.isDisabledOnFirstMessage ??
defaultSettings.typingEmulation
.isDisabledOnFirstMessage) &&
props.inputIndex === 0 &&
props.index === 0 &&
idx() === 0
}
onTransitionEnd={displayNextMessage}
onTransitionEnd={
props.isTransitionDisabled ? undefined : displayNextMessage
}
onCompleted={props.onSubmit}
/>
)}
@ -123,7 +128,7 @@ export const ChatChunk = (props: Props) => {
<InputChatBlock
ref={inputRef}
block={props.input}
inputIndex={props.inputIndex}
chunkIndex={props.index}
hasHostAvatar={
props.theme.chat?.hostAvatar?.isEnabled ??
defaultTheme.chat.hostAvatar.isEnabled

View File

@ -32,6 +32,7 @@ import {
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
import { HTTPError } from 'ky'
import { persist } from '@/utils/persist'
const parseDynamicTheme = (
initialTheme: Theme,
@ -69,13 +70,19 @@ type Props = {
export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatChunkType[]>([
const [chatChunks, setChatChunks] = persist(
createSignal<ChatChunkType[]>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
clientSideActions: props.initialChatReply.clientSideActions,
},
]),
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
clientSideActions: props.initialChatReply.clientSideActions,
},
])
key: `typebot-${props.context.typebot.id}-chatChunks`,
storage: props.context.storage,
}
)
const [dynamicTheme, setDynamicTheme] = createSignal<
ContinueChatResponse['dynamicTheme']
>(props.initialChatReply.dynamicTheme)
@ -276,7 +283,7 @@ export const ConversationContainer = (props: Props) => {
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk
inputIndex={index()}
index={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={theme()}
@ -290,6 +297,7 @@ export const ConversationContainer = (props: Props) => {
(chatChunk.messages.length > 0 && isSending()))
}
hasError={hasError() && index() === chatChunks().length - 1}
isTransitionDisabled={index() !== chatChunks().length - 1}
onNewBubbleDisplayed={handleNewBubbleDisplayed}
onAllBubblesDisplayed={handleAllBubblesDisplayed}
onSubmit={sendMessage}

View File

@ -36,13 +36,14 @@ import { formattedMessages } from '@/utils/formattedMessagesSignal'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { defaultPaymentInputOptions } from '@typebot.io/schemas/features/blocks/inputs/payment/constants'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { persist } from '@/utils/persist'
type Props = {
ref: HTMLDivElement | undefined
block: NonNullable<ContinueChatResponse['input']>
hasHostAvatar: boolean
guestAvatar?: NonNullable<Theme['chat']>['guestAvatar']
inputIndex: number
chunkIndex: number
context: BotContext
isInputPrefillEnabled: boolean
hasError: boolean
@ -52,7 +53,10 @@ type Props = {
}
export const InputChatBlock = (props: Props) => {
const [answer, setAnswer] = createSignal<string>()
const [answer, setAnswer] = persist(createSignal<string>(), {
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
storage: props.context.storage,
})
const [formattedMessage, setFormattedMessage] = createSignal<string>()
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
@ -67,7 +71,7 @@ export const InputChatBlock = (props: Props) => {
createEffect(() => {
const formattedMessage = formattedMessages().findLast(
(message) => props.inputIndex === message.inputIndex
(message) => props.chunkIndex === message.inputIndex
)?.formattedMessage
if (formattedMessage) setFormattedMessage(formattedMessage)
})
@ -101,7 +105,7 @@ export const InputChatBlock = (props: Props) => {
<Input
context={props.context}
block={props.block}
inputIndex={props.inputIndex}
chunkIndex={props.chunkIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled}
existingAnswer={props.hasError ? answer() : undefined}
onTransitionEnd={props.onTransitionEnd}
@ -117,7 +121,7 @@ export const InputChatBlock = (props: Props) => {
const Input = (props: {
context: BotContext
block: NonNullable<ContinueChatResponse['input']>
inputIndex: number
chunkIndex: number
isInputPrefillEnabled: boolean
existingAnswer?: string
onTransitionEnd: () => void
@ -189,7 +193,7 @@ const Input = (props: {
<Switch>
<Match when={!block.options?.isMultipleChoice}>
<Buttons
inputIndex={props.inputIndex}
chunkIndex={props.chunkIndex}
defaultItems={block.items}
options={block.options}
onSubmit={onSubmit}
@ -197,7 +201,6 @@ const Input = (props: {
</Match>
<Match when={block.options?.isMultipleChoice}>
<MultipleChoicesForm
inputIndex={props.inputIndex}
defaultItems={block.items}
options={block.options}
onSubmit={onSubmit}

View File

@ -21,60 +21,50 @@ type Props = {
message: ChatMessage
typingEmulation: Settings['typingEmulation']
isTypingSkipped: boolean
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
onCompleted: (reply?: string) => void
}
export const HostBubble = (props: Props) => {
const onTransitionEnd = (offsetTop?: number) => {
props.onTransitionEnd(offsetTop)
}
const onCompleted = (reply?: string) => {
props.onCompleted(reply)
}
return (
<Switch>
<Match when={props.message.type === BubbleBlockType.TEXT}>
<TextBubble
content={props.message.content as TextBubbleBlock['content']}
isTypingSkipped={props.isTypingSkipped}
typingEmulation={props.typingEmulation}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.IMAGE}>
<ImageBubble
content={props.message.content as ImageBubbleBlock['content']}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.VIDEO}>
<VideoBubble
content={props.message.content as VideoBubbleBlock['content']}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.EMBED}>
<EmbedBubble
content={props.message.content as EmbedBubbleBlock['content']}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === 'custom-embed'}>
<CustomEmbedBubble
content={props.message.content as CustomEmbedBubbleProps['content']}
onTransitionEnd={onTransitionEnd}
onCompleted={onCompleted}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.AUDIO}>
<AudioBubble
content={props.message.content as AudioBubbleBlock['content']}
onTransitionEnd={onTransitionEnd}
/>
</Match>
</Switch>
)
}
export const HostBubble = (props: Props) => (
<Switch>
<Match when={props.message.type === BubbleBlockType.TEXT}>
<TextBubble
content={props.message.content as TextBubbleBlock['content']}
isTypingSkipped={props.isTypingSkipped}
typingEmulation={props.typingEmulation}
onTransitionEnd={props.onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.IMAGE}>
<ImageBubble
content={props.message.content as ImageBubbleBlock['content']}
onTransitionEnd={props.onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.VIDEO}>
<VideoBubble
content={props.message.content as VideoBubbleBlock['content']}
onTransitionEnd={props.onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.EMBED}>
<EmbedBubble
content={props.message.content as EmbedBubbleBlock['content']}
onTransitionEnd={props.onTransitionEnd}
/>
</Match>
<Match when={props.message.type === 'custom-embed'}>
<CustomEmbedBubble
content={props.message.content as CustomEmbedBubbleProps['content']}
onTransitionEnd={props.onTransitionEnd}
onCompleted={props.onCompleted}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.AUDIO}>
<AudioBubble
content={props.message.content as AudioBubbleBlock['content']}
onTransitionEnd={props.onTransitionEnd}
/>
</Match>
</Switch>
)

View File

@ -3,10 +3,11 @@ import { isMobile } from '@/utils/isMobileSignal'
import { AudioBubbleBlock } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { defaultAudioBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/audio/constants'
import clsx from 'clsx'
type Props = {
content: AudioBubbleBlock['content']
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
}
const showAnimationDuration = 400
@ -18,7 +19,9 @@ export const AudioBubble = (props: Props) => {
let isPlayed = false
let ref: HTMLDivElement | undefined
let audioElement: HTMLAudioElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
onMount(() => {
typingTimeout = setTimeout(() => {
@ -26,7 +29,7 @@ export const AudioBubble = (props: Props) => {
isPlayed = true
setIsTyping(false)
setTimeout(
() => props.onTransitionEnd(ref?.offsetTop),
() => props.onTransitionEnd?.(ref?.offsetTop),
showAnimationDuration
)
}, typingDuration)
@ -37,7 +40,13 @@ export const AudioBubble = (props: Props) => {
})
return (
<div class="flex flex-col animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble max-w-full">
<div
@ -53,8 +62,10 @@ export const AudioBubble = (props: Props) => {
ref={audioElement}
src={props.content?.url}
autoplay={
props.content?.isAutoplayEnabled ??
defaultAudioBubbleContent.isAutoplayEnabled
props.onTransitionEnd
? props.content?.isAutoplayEnabled ??
defaultAudioBubbleContent.isAutoplayEnabled
: false
}
class={
'z-10 text-fade-in ' +

View File

@ -7,7 +7,7 @@ import { executeCode } from '@/features/blocks/logic/script/executeScript'
type Props = {
content: CustomEmbedBubbleProps['content']
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
onCompleted: (reply?: string) => void
}
@ -17,7 +17,9 @@ export const showAnimationDuration = 400
export const CustomEmbedBubble = (props: Props) => {
let ref: HTMLDivElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
let containerRef: HTMLDivElement | undefined
onMount(() => {
@ -41,7 +43,7 @@ export const CustomEmbedBubble = (props: Props) => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(
() => props.onTransitionEnd(ref?.offsetTop),
() => props.onTransitionEnd?.(ref?.offsetTop),
showAnimationDuration
)
}, 2000)
@ -52,7 +54,13 @@ export const CustomEmbedBubble = (props: Props) => {
})
return (
<div class="flex flex-col w-full animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col w-full',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble w-full max-w-full">
<div

View File

@ -7,7 +7,7 @@ import { defaultEmbedBubbleContent } from '@typebot.io/schemas/features/blocks/b
type Props = {
content: EmbedBubbleBlock['content']
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
}
let typingTimeout: NodeJS.Timeout
@ -16,13 +16,15 @@ export const showAnimationDuration = 400
export const EmbedBubble = (props: Props) => {
let ref: HTMLDivElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
onMount(() => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd(ref?.offsetTop)
props.onTransitionEnd?.(ref?.offsetTop)
}, showAnimationDuration)
}, 2000)
})
@ -32,7 +34,13 @@ export const EmbedBubble = (props: Props) => {
})
return (
<div class="flex flex-col w-full animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col w-full',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble w-full max-w-full">
<div

View File

@ -7,7 +7,7 @@ import { defaultImageBubbleContent } from '@typebot.io/schemas/features/blocks/b
type Props = {
content: ImageBubbleBlock['content']
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
}
export const showAnimationDuration = 400
@ -19,13 +19,15 @@ let typingTimeout: NodeJS.Timeout
export const ImageBubble = (props: Props) => {
let ref: HTMLDivElement | undefined
let image: HTMLImageElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd(ref?.offsetTop)
props.onTransitionEnd?.(ref?.offsetTop)
}, showAnimationDuration)
}
@ -49,9 +51,11 @@ export const ImageBubble = (props: Props) => {
alt={
props.content?.clickLink?.alt ?? defaultImageBubbleContent.clickLink.alt
}
class={
'text-fade-in w-full ' + (isTyping() ? 'opacity-0' : 'opacity-100')
}
class={clsx(
'w-full',
isTyping() ? 'opacity-0' : 'opacity-100',
props.onTransitionEnd ? 'text-fade-in' : undefined
)}
style={{
'max-height': '512px',
height: isTyping() ? '32px' : 'auto',
@ -62,7 +66,13 @@ export const ImageBubble = (props: Props) => {
)
return (
<div class="flex flex-col animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble max-w-full">
<div

View File

@ -11,7 +11,7 @@ type Props = {
content: TextBubbleBlock['content']
typingEmulation: Settings['typingEmulation']
isTypingSkipped: boolean
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
}
export const showAnimationDuration = 400
@ -20,13 +20,15 @@ let typingTimeout: NodeJS.Timeout
export const TextBubble = (props: Props) => {
let ref: HTMLDivElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd(ref?.offsetTop)
props.onTransitionEnd?.(ref?.offsetTop)
}, showAnimationDuration)
}
@ -50,7 +52,13 @@ export const TextBubble = (props: Props) => {
})
return (
<div class="flex flex-col animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative items-start typebot-host-bubble max-w-full">
<div

View File

@ -15,7 +15,7 @@ import {
type Props = {
content: VideoBubbleBlock['content']
onTransitionEnd: (offsetTop?: number) => void
onTransitionEnd?: (offsetTop?: number) => void
}
export const showAnimationDuration = 400
@ -23,7 +23,9 @@ let typingTimeout: NodeJS.Timeout
export const VideoBubble = (props: Props) => {
let ref: HTMLDivElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
onMount(() => {
const typingDuration =
@ -37,7 +39,7 @@ export const VideoBubble = (props: Props) => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd(ref?.offsetTop)
props.onTransitionEnd?.(ref?.offsetTop)
}, showAnimationDuration)
}, typingDuration)
})
@ -47,7 +49,13 @@ export const VideoBubble = (props: Props) => {
})
return (
<div class="flex flex-col w-full animate-fade-in" ref={ref}>
<div
class={clsx(
'flex flex-col w-full',
props.onTransitionEnd ? 'animate-fade-in' : undefined
)}
ref={ref}
>
<div class="flex w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble overflow-hidden w-full max-w-full">
<div
@ -69,7 +77,7 @@ export const VideoBubble = (props: Props) => {
}
>
<video
autoplay
autoplay={props.onTransitionEnd ? false : true}
src={props.content?.url}
controls
class={

View File

@ -7,7 +7,7 @@ import { For, Show, createSignal, onMount } from 'solid-js'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
type Props = {
inputIndex: number
chunkIndex: number
defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void
@ -66,7 +66,7 @@ export const Buttons = (props: Props) => {
>
{item.content}
</Button>
{props.inputIndex === 0 && props.defaultItems.length === 1 && (
{props.chunkIndex === 0 && props.defaultItems.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-200 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-150" />

View File

@ -8,7 +8,6 @@ import { SearchInput } from '@/components/inputs/SearchInput'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
type Props = {
inputIndex: number
defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void

View File

@ -17,7 +17,6 @@ export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal<string | number>(
props.defaultValue ?? ''
)
// eslint-disable-next-line solid/reactivity
const [staticValue, bindValue, targetValue] = numberInputHelper(() =>
inputValue()
)

View File

@ -14,6 +14,11 @@ import { isDefined } from '@typebot.io/lib'
import { BubbleParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
import { getPaymentInProgressInStorage } from '@/features/blocks/inputs/payment/helpers/paymentInProgressStorage'
import {
getBotOpenedStateFromStorage,
removeBotOpenedStateInStorage,
setBotOpenedStateInStorage,
} from '@/utils/storage'
export type BubbleProps = BotProps &
BubbleParams & {
@ -33,7 +38,6 @@ export const Bubble = (props: BubbleProps) => {
])
const [isMounted, setIsMounted] = createSignal(true)
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isPreviewMessageDisplayed, setIsPreviewMessageDisplayed] =
@ -48,7 +52,6 @@ export const Bubble = (props: BubbleProps) => {
const [isBotOpened, setIsBotOpened] = createSignal(false)
const [isBotStarted, setIsBotStarted] = createSignal(false)
const [buttonSize, setButtonSize] = createSignal(
// eslint-disable-next-line solid/reactivity
parseButtonSize(bubbleProps.theme?.button?.size ?? 'medium')
)
createEffect(() => {
@ -62,8 +65,8 @@ export const Bubble = (props: BubbleProps) => {
const autoShowDelay = bubbleProps.autoShowDelay
const previewMessageAutoShowDelay =
bubbleProps.previewMessage?.autoShowDelay
const paymentInProgress = getPaymentInProgressInStorage()
if (paymentInProgress) openBot()
if (getBotOpenedStateFromStorage() || getPaymentInProgressInStorage())
openBot()
if (isDefined(autoShowDelay)) {
setTimeout(() => {
openBot()
@ -113,6 +116,7 @@ export const Bubble = (props: BubbleProps) => {
const closeBot = () => {
setIsBotOpened(false)
removeBotOpenedStateInStorage()
if (isBotOpened()) bubbleProps.onClose?.()
}
@ -146,6 +150,11 @@ export const Bubble = (props: BubbleProps) => {
} else setIsMounted(false)
}
const handleOnChatStatePersisted = (isPersisted: boolean) => {
botProps.onChatStatePersisted?.(isPersisted)
if (isPersisted) setBotOpenedStateInStorage()
}
return (
<Show when={isMounted()}>
<style>{styles}</style>
@ -195,6 +204,7 @@ export const Bubble = (props: BubbleProps) => {
<Show when={isBotStarted()}>
<Bot
{...botProps}
onChatStatePersisted={handleOnChatStatePersisted}
prefilledVariables={prefilledVariables()}
class="rounded-lg"
progressBarRef={progressBarContainerRef}

View File

@ -12,6 +12,11 @@ import { isDefined, isNotDefined } from '@typebot.io/lib'
import { PopupParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
import { getPaymentInProgressInStorage } from '@/features/blocks/inputs/payment/helpers/paymentInProgressStorage'
import {
getBotOpenedStateFromStorage,
removeBotOpenedStateInStorage,
setBotOpenedStateInStorage,
} from '@/utils/storage'
export type PopupProps = BotProps &
PopupParams & {
@ -32,18 +37,18 @@ export const Popup = (props: PopupProps) => {
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isBotOpened, setIsBotOpened] = createSignal(
// eslint-disable-next-line solid/reactivity
popupProps.isOpen ?? false
)
const [isBotOpened, setIsBotOpened] = createSignal(popupProps.isOpen ?? false)
onMount(() => {
const paymentInProgress = getPaymentInProgressInStorage()
if (popupProps.defaultOpen || paymentInProgress) openBot()
if (
popupProps.defaultOpen ||
getPaymentInProgressInStorage() ||
getBotOpenedStateFromStorage()
)
openBot()
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = popupProps.autoShowDelay
if (isDefined(autoShowDelay)) {
@ -99,12 +104,18 @@ export const Popup = (props: PopupProps) => {
popupProps.onClose?.()
document.body.style.overflow = 'auto'
document.removeEventListener('pointerdown', closeBot)
removeBotOpenedStateInStorage()
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
const handleOnChatStatePersisted = (isPersisted: boolean) => {
botProps.onChatStatePersisted?.(isPersisted)
if (isPersisted) setBotOpenedStateInStorage()
}
return (
<Show when={isBotOpened()}>
<style>{styles}</style>
@ -136,7 +147,11 @@ export const Popup = (props: PopupProps) => {
}}
on:pointerdown={stopPropagation}
>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
<Bot
{...botProps}
prefilledVariables={prefilledVariables()}
onChatStatePersisted={handleOnChatStatePersisted}
/>
</div>
</div>
</div>

View File

@ -11,6 +11,7 @@ export type BotContext = {
isPreview: boolean
apiHost?: string
sessionId: string
storage: 'local' | 'session' | undefined
}
export type InitialChatReply = StartChatResponse & {

View File

@ -1,4 +1,3 @@
/* eslint-disable solid/reactivity */
import { initGoogleAnalytics } from '@/lib/gtag'
import { gtmBodyElement } from '@/lib/gtm'
import { initPixel } from '@/lib/pixel'

View File

@ -0,0 +1,54 @@
// Copied from https://github.com/solidjs-community/solid-primitives/blob/main/packages/storage/src/types.ts
// Simplifying and adding a `isEnabled` prop
/* eslint-disable @typescript-eslint/no-explicit-any */
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
import type { Setter, Signal } from 'solid-js'
import { untrack } from 'solid-js'
import { reconcile } from 'solid-js/store'
type Params = {
key: string
storage: 'local' | 'session' | undefined
}
export function persist<T>(signal: Signal<T>, params: Params): Signal<T> {
if (!params.storage) return signal
const storage = parseRememberUserStorage(
params.storage || defaultSettings.general.rememberUser.storage
)
const serialize: (data: T) => string = JSON.stringify.bind(JSON)
const deserialize: (data: string) => T = JSON.parse.bind(JSON)
const init = storage.getItem(params.key)
const set =
typeof signal[0] === 'function'
? (data: string) => (signal[1] as any)(() => deserialize(data))
: (data: string) => (signal[1] as any)(reconcile(deserialize(data)))
if (init) set(init)
return [
signal[0],
typeof signal[0] === 'function'
? (value?: T | ((prev: T) => T)) => {
const output = (signal[1] as Setter<T>)(value as any)
if (value) storage.setItem(params.key, serialize(output))
else storage.removeItem(params.key)
return output
}
: (...args: any[]) => {
;(signal[1] as any)(...args)
const value = serialize(untrack(() => signal[0] as any))
storage.setItem(params.key, value)
},
] as typeof signal
}
const parseRememberUserStorage = (
storage: 'local' | 'session' | undefined
): typeof localStorage | typeof sessionStorage =>
(storage ?? defaultSettings.general.rememberUser.storage) === 'session'
? sessionStorage
: localStorage

View File

@ -1,11 +1,14 @@
const sessionStorageKey = 'resultId'
import { InitialChatReply } from '@/types'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
const storageResultIdKey = 'resultId'
export const getExistingResultIdFromStorage = (typebotId?: string) => {
if (!typebotId) return
try {
return (
sessionStorage.getItem(`${sessionStorageKey}-${typebotId}`) ??
localStorage.getItem(`${sessionStorageKey}-${typebotId}`) ??
sessionStorage.getItem(`${storageResultIdKey}-${typebotId}`) ??
localStorage.getItem(`${storageResultIdKey}-${typebotId}`) ??
undefined
)
} catch {
@ -17,13 +20,86 @@ export const setResultInStorage =
(storageType: 'local' | 'session' = 'session') =>
(typebotId: string, resultId: string) => {
try {
;(storageType === 'session' ? localStorage : sessionStorage).removeItem(
`${sessionStorageKey}-${typebotId}`
parseRememberUserStorage(storageType).setItem(
`${storageResultIdKey}-${typebotId}`,
resultId
)
return (
storageType === 'session' ? sessionStorage : localStorage
).setItem(`${sessionStorageKey}-${typebotId}`, resultId)
} catch {
/* empty */
}
}
export const getInitialChatReplyFromStorage = (
typebotId: string | undefined
) => {
if (!typebotId) return
try {
const rawInitialChatReply =
sessionStorage.getItem(`typebot-${typebotId}-initialChatReply`) ??
localStorage.getItem(`typebot-${typebotId}-initialChatReply`)
if (!rawInitialChatReply) return
return JSON.parse(rawInitialChatReply) as InitialChatReply
} catch {
/* empty */
}
}
export const setInitialChatReplyInStorage = (
initialChatReply: InitialChatReply,
{
typebotId,
storage,
}: {
typebotId: string
storage?: 'local' | 'session'
}
) => {
try {
const rawInitialChatReply = JSON.stringify(initialChatReply)
parseRememberUserStorage(storage).setItem(
`typebot-${typebotId}-initialChatReply`,
rawInitialChatReply
)
} catch {
/* empty */
}
}
export const setBotOpenedStateInStorage = () => {
try {
sessionStorage.setItem(`typebot-botOpened`, 'true')
} catch {
/* empty */
}
}
export const removeBotOpenedStateInStorage = () => {
try {
sessionStorage.removeItem(`typebot-botOpened`)
} catch {
/* empty */
}
}
export const getBotOpenedStateFromStorage = () => {
try {
return sessionStorage.getItem(`typebot-botOpened`) === 'true'
} catch {
return false
}
}
export const parseRememberUserStorage = (
storage: 'local' | 'session' | undefined
): typeof localStorage | typeof sessionStorage =>
(storage ?? defaultSettings.general.rememberUser.storage) === 'session'
? sessionStorage
: localStorage
export const wipeExistingChatStateInStorage = (typebotId: string) => {
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(`typebot-${typebotId}`)) localStorage.removeItem(key)
})
Object.keys(sessionStorage).forEach((key) => {
if (key.startsWith(`typebot-${typebotId}`)) sessionStorage.removeItem(key)
})
}

View File

@ -1,4 +1,3 @@
/* eslint-disable solid/reactivity */
import { BubbleProps } from './features/bubble'
import { PopupProps } from './features/popup'
import { BotProps } from './components/Bot'

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/nextjs",
"version": "0.2.48",
"version": "0.2.49",
"description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.2.48",
"version": "0.2.49",
"description": "Convenient library to display typebots on your React app",
"main": "dist/index.js",
"types": "dist/index.d.ts",