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 <SwitchWithRelatedSettings
label={'Remember user'} 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={ initialValue={
generalSettings?.rememberUser?.isEnabled ?? generalSettings?.rememberUser?.isEnabled ??
(isDefined(generalSettings?.isNewResultOnRefreshEnabled) (isDefined(generalSettings?.isNewResultOnRefreshEnabled)
@ -112,7 +112,7 @@ export const GeneralSettingsForm = ({
<Tag size="sm" bgColor={keyBg}> <Tag size="sm" bgColor={keyBg}>
local local
</Tag>{' '} </Tag>{' '}
to remember the user forever. to remember the user forever on the same device.
</Text> </Text>
</Stack> </Stack>
</MoreInfoTooltip> </MoreInfoTooltip>

View File

@ -142,7 +142,7 @@
}, },
{ {
"group": "Settings", "group": "Settings",
"pages": ["settings/overview"] "pages": ["settings/overview", "settings/remember-user"]
}, },
{ {
"group": "Deploy", "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. - **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. - **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 ## 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) => export const findResult = ({ id }: Props) =>
prisma.result.findFirst({ prisma.result.findFirst({
where: { id }, where: { id, isArchived: { not: true } },
select: { select: {
id: true, id: true,
variables: true, variables: true,

View File

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

View File

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

View File

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

View File

@ -2,7 +2,11 @@ import { createSignal, onCleanup, onMount } from 'solid-js'
import { isMobile } from '@/utils/isMobileSignal' import { isMobile } from '@/utils/isMobileSignal'
import { Avatar } from '../avatars/Avatar' import { Avatar } from '../avatars/Avatar'
type Props = { hostAvatarSrc?: string; hideAvatar?: boolean } type Props = {
hostAvatarSrc?: string
hideAvatar?: boolean
isTransitionDisabled?: boolean
}
export const AvatarSideContainer = (props: Props) => { export const AvatarSideContainer = (props: Props) => {
let avatarContainer: HTMLDivElement | undefined let avatarContainer: HTMLDivElement | undefined
@ -40,7 +44,9 @@ export const AvatarSideContainer = (props: Props) => {
} }
style={{ style={{
top: `${top()}px`, 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} /> <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'> & { type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
theme: Theme theme: Theme
settings: Settings settings: Settings
inputIndex: number index: number
context: BotContext context: BotContext
hasError: boolean hasError: boolean
hideAvatar: boolean hideAvatar: boolean
streamingMessageId: ChatChunkType['streamingMessageId'] streamingMessageId: ChatChunkType['streamingMessageId']
isTransitionDisabled?: boolean
onNewBubbleDisplayed: (blockId: string) => Promise<void> onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: (top?: number) => void onScrollToBottom: (top?: number) => void
onSubmit: (input?: string) => void onSubmit: (input?: string) => void
@ -26,7 +27,9 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
export const ChatChunk = (props: Props) => { export const ChatChunk = (props: Props) => {
let inputRef: HTMLDivElement | undefined let inputRef: HTMLDivElement | undefined
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0) const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(
props.isTransitionDisabled ? props.messages.length : 0
)
const [lastBubbleOffsetTop, setLastBubbleOffsetTop] = createSignal<number>() const [lastBubbleOffsetTop, setLastBubbleOffsetTop] = createSignal<number>()
onMount(() => { onMount(() => {
@ -45,7 +48,6 @@ export const ChatChunk = (props: Props) => {
defaultSettings.typingEmulation.delayBetweenBubbles) > 0 && defaultSettings.typingEmulation.delayBetweenBubbles) > 0 &&
displayedMessageIndex() < props.messages.length - 1 displayedMessageIndex() < props.messages.length - 1
) { ) {
// eslint-disable-next-line solid/reactivity
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout( setTimeout(
resolve, resolve,
@ -82,6 +84,7 @@ export const ChatChunk = (props: Props) => {
<AvatarSideContainer <AvatarSideContainer
hostAvatarSrc={props.theme.chat?.hostAvatar?.url} hostAvatarSrc={props.theme.chat?.hostAvatar?.url}
hideAvatar={props.hideAvatar} hideAvatar={props.hideAvatar}
isTransitionDisabled={props.isTransitionDisabled}
/> />
</Show> </Show>
@ -108,10 +111,12 @@ export const ChatChunk = (props: Props) => {
(props.settings.typingEmulation?.isDisabledOnFirstMessage ?? (props.settings.typingEmulation?.isDisabledOnFirstMessage ??
defaultSettings.typingEmulation defaultSettings.typingEmulation
.isDisabledOnFirstMessage) && .isDisabledOnFirstMessage) &&
props.inputIndex === 0 && props.index === 0 &&
idx() === 0 idx() === 0
} }
onTransitionEnd={displayNextMessage} onTransitionEnd={
props.isTransitionDisabled ? undefined : displayNextMessage
}
onCompleted={props.onSubmit} onCompleted={props.onSubmit}
/> />
)} )}
@ -123,7 +128,7 @@ export const ChatChunk = (props: Props) => {
<InputChatBlock <InputChatBlock
ref={inputRef} ref={inputRef}
block={props.input} block={props.input}
inputIndex={props.inputIndex} chunkIndex={props.index}
hasHostAvatar={ hasHostAvatar={
props.theme.chat?.hostAvatar?.isEnabled ?? props.theme.chat?.hostAvatar?.isEnabled ??
defaultTheme.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 { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery' import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
import { HTTPError } from 'ky' import { HTTPError } from 'ky'
import { persist } from '@/utils/persist'
const parseDynamicTheme = ( const parseDynamicTheme = (
initialTheme: Theme, initialTheme: Theme,
@ -69,13 +70,19 @@ type Props = {
export const ConversationContainer = (props: Props) => { export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined let chatContainer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatChunkType[]>([ const [chatChunks, setChatChunks] = persist(
createSignal<ChatChunkType[]>([
{ {
input: props.initialChatReply.input, input: props.initialChatReply.input,
messages: props.initialChatReply.messages, messages: props.initialChatReply.messages,
clientSideActions: props.initialChatReply.clientSideActions, clientSideActions: props.initialChatReply.clientSideActions,
}, },
]) ]),
{
key: `typebot-${props.context.typebot.id}-chatChunks`,
storage: props.context.storage,
}
)
const [dynamicTheme, setDynamicTheme] = createSignal< const [dynamicTheme, setDynamicTheme] = createSignal<
ContinueChatResponse['dynamicTheme'] ContinueChatResponse['dynamicTheme']
>(props.initialChatReply.dynamicTheme) >(props.initialChatReply.dynamicTheme)
@ -276,7 +283,7 @@ export const ConversationContainer = (props: Props) => {
<For each={chatChunks()}> <For each={chatChunks()}>
{(chatChunk, index) => ( {(chatChunk, index) => (
<ChatChunk <ChatChunk
inputIndex={index()} index={index()}
messages={chatChunk.messages} messages={chatChunk.messages}
input={chatChunk.input} input={chatChunk.input}
theme={theme()} theme={theme()}
@ -290,6 +297,7 @@ export const ConversationContainer = (props: Props) => {
(chatChunk.messages.length > 0 && isSending())) (chatChunk.messages.length > 0 && isSending()))
} }
hasError={hasError() && index() === chatChunks().length - 1} hasError={hasError() && index() === chatChunks().length - 1}
isTransitionDisabled={index() !== chatChunks().length - 1}
onNewBubbleDisplayed={handleNewBubbleDisplayed} onNewBubbleDisplayed={handleNewBubbleDisplayed}
onAllBubblesDisplayed={handleAllBubblesDisplayed} onAllBubblesDisplayed={handleAllBubblesDisplayed}
onSubmit={sendMessage} onSubmit={sendMessage}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import {
type Props = { type Props = {
content: VideoBubbleBlock['content'] content: VideoBubbleBlock['content']
onTransitionEnd: (offsetTop?: number) => void onTransitionEnd?: (offsetTop?: number) => void
} }
export const showAnimationDuration = 400 export const showAnimationDuration = 400
@ -23,7 +23,9 @@ let typingTimeout: NodeJS.Timeout
export const VideoBubble = (props: Props) => { export const VideoBubble = (props: Props) => {
let ref: HTMLDivElement | undefined let ref: HTMLDivElement | undefined
const [isTyping, setIsTyping] = createSignal(true) const [isTyping, setIsTyping] = createSignal(
props.onTransitionEnd ? true : false
)
onMount(() => { onMount(() => {
const typingDuration = const typingDuration =
@ -37,7 +39,7 @@ export const VideoBubble = (props: Props) => {
if (!isTyping()) return if (!isTyping()) return
setIsTyping(false) setIsTyping(false)
setTimeout(() => { setTimeout(() => {
props.onTransitionEnd(ref?.offsetTop) props.onTransitionEnd?.(ref?.offsetTop)
}, showAnimationDuration) }, showAnimationDuration)
}, typingDuration) }, typingDuration)
}) })
@ -47,7 +49,13 @@ export const VideoBubble = (props: Props) => {
}) })
return ( 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 w-full items-center">
<div class="flex relative z-10 items-start typebot-host-bubble overflow-hidden w-full max-w-full"> <div class="flex relative z-10 items-start typebot-host-bubble overflow-hidden w-full max-w-full">
<div <div
@ -69,7 +77,7 @@ export const VideoBubble = (props: Props) => {
} }
> >
<video <video
autoplay autoplay={props.onTransitionEnd ? false : true}
src={props.content?.url} src={props.content?.url}
controls controls
class={ 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' import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
type Props = { type Props = {
inputIndex: number chunkIndex: number
defaultItems: ChoiceInputBlock['items'] defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options'] options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void onSubmit: (value: InputSubmitContent) => void
@ -66,7 +66,7 @@ export const Buttons = (props: Props) => {
> >
{item.content} {item.content}
</Button> </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="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="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" /> <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' import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
type Props = { type Props = {
inputIndex: number
defaultItems: ChoiceInputBlock['items'] defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options'] options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void onSubmit: (value: InputSubmitContent) => void

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
/* eslint-disable solid/reactivity */
import { initGoogleAnalytics } from '@/lib/gtag' import { initGoogleAnalytics } from '@/lib/gtag'
import { gtmBodyElement } from '@/lib/gtm' import { gtmBodyElement } from '@/lib/gtm'
import { initPixel } from '@/lib/pixel' 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) => { export const getExistingResultIdFromStorage = (typebotId?: string) => {
if (!typebotId) return if (!typebotId) return
try { try {
return ( return (
sessionStorage.getItem(`${sessionStorageKey}-${typebotId}`) ?? sessionStorage.getItem(`${storageResultIdKey}-${typebotId}`) ??
localStorage.getItem(`${sessionStorageKey}-${typebotId}`) ?? localStorage.getItem(`${storageResultIdKey}-${typebotId}`) ??
undefined undefined
) )
} catch { } catch {
@ -17,13 +20,86 @@ export const setResultInStorage =
(storageType: 'local' | 'session' = 'session') => (storageType: 'local' | 'session' = 'session') =>
(typebotId: string, resultId: string) => { (typebotId: string, resultId: string) => {
try { try {
;(storageType === 'session' ? localStorage : sessionStorage).removeItem( parseRememberUserStorage(storageType).setItem(
`${sessionStorageKey}-${typebotId}` `${storageResultIdKey}-${typebotId}`,
resultId
) )
return (
storageType === 'session' ? sessionStorage : localStorage
).setItem(`${sessionStorageKey}-${typebotId}`, resultId)
} catch { } catch {
/* empty */ /* 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 { BubbleProps } from './features/bubble'
import { PopupProps } from './features/popup' import { PopupProps } from './features/popup'
import { BotProps } from './components/Bot' import { BotProps } from './components/Bot'

View File

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

View File

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