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

@ -85,7 +85,7 @@ export const GeneralSettingsForm = ({
/>
<SwitchWithRelatedSettings
label={'Remember user'}
moreInfoContent="If enabled, user previous variables will be prefilled and his new answers will override the previous ones."
moreInfoContent="If enabled, the chat state will be restored if the user comes back after exiting."
initialValue={
generalSettings?.rememberUser?.isEnabled ??
(isDefined(generalSettings?.isNewResultOnRefreshEnabled)
@ -112,7 +112,7 @@ export const GeneralSettingsForm = ({
<Tag size="sm" bgColor={keyBg}>
local
</Tag>{' '}
to remember the user forever.
to remember the user forever on the same device.
</Text>
</Stack>
</MoreInfoTooltip>

View File

@ -142,7 +142,7 @@
},
{
"group": "Settings",
"pages": ["settings/overview"]
"pages": ["settings/overview", "settings/remember-user"]
},
{
"group": "Deploy",

View File

@ -14,7 +14,7 @@ The general settings represent the general behaviors of your typebot.
- **Prefill input**: If enabled, the inputs will be automatically pre-filled whenever their associated variable has a value.
- **Hide query params on bot start**: If enabled, the query params will be hidden when the bot starts.
- **Remember user**: If enabled, user previous variables will be prefilled and his new answers will override the previous ones.
- [**Remember user**](./remember-user)
## Typing emulation

View File

@ -0,0 +1,13 @@
---
title: Remember user
icon: bookmark
---
Head over to the `Settings` tab of your typebot, under the `General` section you can find the `Remember user` setting.
This setting allows you to save the chat session state into the user's web browser storage. It means that if he answers a question and then closes the chat, the next time he opens it, the chat will be in the same state as it was before.
There are 2 storage options:
- **Local storage**: The chat state will be saved in the user's web browser. It will be available only on the same device and web browser.
- **Session storage**: The chat state will be saved in the user's web browser. It will be available only on the same device and web browser, but it will be deleted when the user closes the current tab or the web browser.

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",