2
0

(theme) Add progress bar option (#1276)

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

- **New Features**
- Introduced progress bar functionality across various components for a
more interactive user experience.
	- Added progress tracking and display in chat sessions.
- **Enhancements**
- Adjusted layout height calculations in the settings and theme pages
for consistency.
- Improved theme customization options with progress bar styling and
placement settings.
- **Bug Fixes**
- Fixed dynamic height calculation issues in settings and theme side
menus by standardizing heights.
- **Style**
- Added custom styling properties for the progress bar in chat
interfaces.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-02-23 08:31:14 +01:00
committed by GitHub
parent f2b21746bc
commit 2d7ccf17c0
30 changed files with 535 additions and 90 deletions

View File

@ -15,8 +15,9 @@ import {
Box,
} from '@chakra-ui/react'
import { useTranslate } from '@tolgee/react'
import React, { ChangeEvent, useState } from 'react'
import React, { useState } from 'react'
import tinyColor from 'tinycolor2'
import { useDebouncedCallback } from 'use-debounce'
const colorsSelection: `#${string}`[] = [
'#666460',
@ -42,9 +43,9 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
const [color, setColor] = useState(defaultValue ?? '')
const displayedValue = value ?? color
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) => {
setColor(e.target.value)
onColorChange(e.target.value)
const handleColorChange = (color: string) => {
setColor(color)
onColorChange(color)
}
const handleClick = (color: string) => () => {
@ -103,7 +104,7 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
aria-label={t('colorPicker.colorValue.ariaLabel')}
size="sm"
value={displayedValue}
onChange={handleColorChange}
onChange={(e) => handleColorChange(e.target.value)}
/>
<NativeColorPicker
size="sm"
@ -124,8 +125,12 @@ const NativeColorPicker = ({
...props
}: {
color: string
onColorChange: (e: ChangeEvent<HTMLInputElement>) => void
onColorChange: (color: string) => void
} & ButtonProps) => {
const debouncedOnColorChange = useDebouncedCallback((color: string) => {
onColorChange(color)
}, 200)
return (
<>
<Button as="label" htmlFor="native-picker" {...props}>
@ -136,7 +141,7 @@ const NativeColorPicker = ({
display="none"
id="native-picker"
value={color}
onChange={onColorChange}
onChange={(e) => debouncedOnColorChange(e.target.value)}
/>
</>
)

View File

@ -6,6 +6,7 @@ import { TypebotHeader } from '@/features/editor/components/TypebotHeader'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
import { env } from '@typebot.io/env'
import { headerHeight } from '@/features/editor/constants'
export const SettingsPage = () => {
const { typebot, is404 } = useTypebot()
@ -15,7 +16,7 @@ export const SettingsPage = () => {
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Settings` : 'Settings'} />
<TypebotHeader />
<Flex h="full" w="full">
<Flex height={`calc(100vh - ${headerHeight}px)`} w="full">
<SettingsSideMenu />
<Flex flex="1">
{typebot && (

View File

@ -20,7 +20,6 @@ import { GeneralSettingsForm } from './GeneralSettingsForm'
import { MetadataForm } from './MetadataForm'
import { TypingEmulationForm } from './TypingEmulationForm'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { headerHeight } from '@/features/editor/constants'
import { SecurityForm } from './SecurityForm'
export const SettingsSideMenu = () => {
@ -52,7 +51,7 @@ export const SettingsSideMenu = () => {
<Stack
flex="1"
maxW="400px"
height={`calc(100vh - ${headerHeight}px)`}
height="full"
borderRightWidth={1}
pt={10}
spacing={10}

View File

@ -5,6 +5,7 @@ import { Flex } from '@chakra-ui/react'
import { Standard } from '@typebot.io/nextjs'
import { ThemeSideMenu } from './ThemeSideMenu'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
import { headerHeight } from '@/features/editor/constants'
export const ThemePage = () => {
const { typebot, is404 } = useTypebot()
@ -14,7 +15,7 @@ export const ThemePage = () => {
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Theme` : 'Theme'} />
<TypebotHeader />
<Flex h="full" w="full">
<Flex w="full" height={`calc(100vh - ${headerHeight}px)`}>
<ThemeSideMenu />
<Flex flex="1">
{typebot && (

View File

@ -13,7 +13,6 @@ import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas'
import React from 'react'
import { CustomCssSettings } from './CustomCssSettings'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { headerHeight } from '@/features/editor/constants'
import { ChatThemeSettings } from './chat/ChatThemeSettings'
import { GeneralSettings } from './general/GeneralSettings'
import { ThemeTemplates } from './ThemeTemplates'
@ -61,7 +60,7 @@ export const ThemeSideMenu = () => {
<Stack
flex="1"
maxW="400px"
height={`calc(100vh - ${headerHeight}px)`}
h="full"
borderRightWidth={1}
pt={10}
spacing={10}

View File

@ -34,56 +34,61 @@ export const BackgroundContent = ({
const handleContentChange = (content: string) =>
onBackgroundContentChange(content)
switch (background?.type) {
case BackgroundType.COLOR:
return (
<Flex justify="space-between" align="center">
<Text>{t('theme.sideMenu.global.background.color')}</Text>
<ColorPicker
value={
background.content ?? defaultTheme.general.background.content
}
onColorChange={handleContentChange}
/>
</Flex>
)
case BackgroundType.IMAGE:
if (!typebot) return null
return (
<Popover isLazy placement="top">
<PopoverTrigger>
{isNotEmpty(background.content) ? (
<Image
src={background.content}
alt={t('theme.sideMenu.global.background.image.alt')}
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
transition="filter 200ms"
rounded="md"
maxH="200px"
objectFit="cover"
/>
) : (
<Button>
{t('theme.sideMenu.global.background.image.button')}
</Button>
)}
</PopoverTrigger>
<Portal>
<PopoverContent p="4" w="500px">
<ImageUploadContent
uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
fileName: 'background',
}}
defaultUrl={background.content}
onSubmit={handleContentChange}
excludedTabs={['giphy', 'icon']}
/>
</PopoverContent>
</Portal>
</Popover>
)
if (
(background?.type ?? defaultTheme.general.background.type) ===
BackgroundType.IMAGE
) {
if (!typebot) return null
return (
<Popover isLazy placement="top">
<PopoverTrigger>
{isNotEmpty(background?.content) ? (
<Image
src={background?.content}
alt={t('theme.sideMenu.global.background.image.alt')}
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
transition="filter 200ms"
rounded="md"
maxH="200px"
objectFit="cover"
/>
) : (
<Button>
{t('theme.sideMenu.global.background.image.button')}
</Button>
)}
</PopoverTrigger>
<Portal>
<PopoverContent p="4" w="500px">
<ImageUploadContent
uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
fileName: 'background',
}}
defaultUrl={background?.content}
onSubmit={handleContentChange}
excludedTabs={['giphy', 'icon']}
/>
</PopoverContent>
</Portal>
</Popover>
)
}
if (
(background?.type ?? defaultTheme.general.background.type) ===
BackgroundType.COLOR
) {
return (
<Flex justify="space-between" align="center">
<Text>{t('theme.sideMenu.global.background.color')}</Text>
<ColorPicker
value={background?.content ?? defaultTheme.general.background.content}
onColorChange={handleContentChange}
/>
</Flex>
)
}
return null
}

View File

@ -3,7 +3,10 @@ import { Stack, Text } from '@chakra-ui/react'
import { Background } from '@typebot.io/schemas'
import React from 'react'
import { BackgroundContent } from './BackgroundContent'
import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants'
import {
BackgroundType,
defaultTheme,
} from '@typebot.io/schemas/features/typebot/theme/constants'
import { useTranslate } from '@tolgee/react'
type Props = {
@ -11,8 +14,6 @@ type Props = {
onBackgroundChange: (newBackground: Background) => void
}
const defaultBackgroundType = BackgroundType.NONE
export const BackgroundSelector = ({
background,
onBackgroundChange,
@ -20,11 +21,10 @@ export const BackgroundSelector = ({
const { t } = useTranslate()
const handleBackgroundTypeChange = (type: BackgroundType) =>
background &&
onBackgroundChange({ ...background, type, content: undefined })
const handleBackgroundContentChange = (content: string) =>
background && onBackgroundChange({ ...background, content })
onBackgroundChange({ ...background, content })
return (
<Stack spacing={4}>
@ -44,7 +44,7 @@ export const BackgroundSelector = ({
value: BackgroundType.NONE,
},
]}
value={background?.type ?? defaultBackgroundType}
value={background?.type ?? defaultTheme.general.background.type}
onSelect={handleBackgroundTypeChange}
/>
<BackgroundContent

View File

@ -3,13 +3,14 @@ import { GoogleFontForm } from './GoogleFontForm'
import { CustomFontForm } from './CustomFontForm'
type Props = {
font: Font
font: Font | undefined
onFontChange: (font: Font) => void
}
export const FontForm = ({ font, onFontChange }: Props) => {
if (typeof font === 'string' || font.type === 'Google')
if (!font || typeof font === 'string' || font?.type === 'Google')
return <GoogleFontForm font={font} onFontChange={onFontChange} />
if (font.type === 'Custom')
return <CustomFontForm font={font} onFontChange={onFontChange} />
return null
}

View File

@ -6,7 +6,7 @@ import {
useDisclosure,
Text,
} from '@chakra-ui/react'
import { Background, Font, Theme } from '@typebot.io/schemas'
import { Background, Font, ProgressBar, Theme } from '@typebot.io/schemas'
import React from 'react'
import { BackgroundSelector } from './BackgroundSelector'
import { LockTag } from '@/features/billing/components/LockTag'
@ -24,6 +24,7 @@ import { env } from '@typebot.io/env'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { RadioButtons } from '@/components/inputs/RadioButtons'
import { FontForm } from './FontForm'
import { ProgressBarForm } from './ProgressBarForm'
type Props = {
isBrandingEnabled: boolean
@ -63,6 +64,9 @@ export const GeneralSettings = ({
const handleBackgroundChange = (background: Background) =>
onGeneralThemeChange({ ...generalTheme, background })
const updateProgressBar = (progressBar: ProgressBar) =>
onGeneralThemeChange({ ...generalTheme, progressBar })
const updateBranding = () => {
if (isBrandingEnabled && isWorkspaceFreePlan) return
if (
@ -118,15 +122,16 @@ export const GeneralSettings = ({
defaultValue={fontType}
onSelect={updateFontType}
/>
<FontForm
font={generalTheme?.font ?? defaultTheme.general.font}
onFontChange={updateFont}
/>
<FontForm font={generalTheme?.font} onFontChange={updateFont} />
</Stack>
<BackgroundSelector
background={generalTheme?.background ?? defaultTheme.general.background}
background={generalTheme?.background}
onBackgroundChange={handleBackgroundChange}
/>
<ProgressBarForm
progressBar={generalTheme?.progressBar}
onProgressBarChange={updateProgressBar}
/>
</Stack>
)
}

View File

@ -1,16 +1,18 @@
import { Select } from '@/components/inputs/Select'
import { env } from '@typebot.io/env'
import { GoogleFont } from '@typebot.io/schemas'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { useState, useEffect } from 'react'
type Props = {
font: GoogleFont | string
font: GoogleFont | string | undefined
onFontChange: (font: GoogleFont) => void
}
export const GoogleFontForm = ({ font, onFontChange }: Props) => {
const [currentFont, setCurrentFont] = useState(
typeof font === 'string' ? font : font.family
(typeof font === 'string' ? font : font?.family) ??
defaultTheme.general.font.family
)
const [googleFonts, setGoogleFonts] = useState<string[]>([])

View File

@ -0,0 +1,101 @@
import { ColorPicker } from '@/components/ColorPicker'
import { DropdownList } from '@/components/DropdownList'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
import { NumberInput } from '@/components/inputs'
import { HStack, Text } from '@chakra-ui/react'
import { ProgressBar } from '@typebot.io/schemas'
import {
defaultTheme,
progressBarPlacements,
progressBarPositions,
} from '@typebot.io/schemas/features/typebot/theme/constants'
type Props = {
progressBar: ProgressBar | undefined
onProgressBarChange: (progressBar: ProgressBar) => void
}
export const ProgressBarForm = ({
progressBar,
onProgressBarChange,
}: Props) => {
const updateEnabled = (isEnabled: boolean) =>
onProgressBarChange({ ...progressBar, isEnabled })
const updateColor = (color: string) =>
onProgressBarChange({ ...progressBar, color })
const updateBackgroundColor = (backgroundColor: string) =>
onProgressBarChange({ ...progressBar, backgroundColor })
const updatePlacement = (placement: (typeof progressBarPlacements)[number]) =>
onProgressBarChange({ ...progressBar, placement })
const updatePosition = (position: (typeof progressBarPositions)[number]) =>
onProgressBarChange({ ...progressBar, position })
const updateThickness = (thickness?: number) =>
onProgressBarChange({ ...progressBar, thickness })
return (
<SwitchWithRelatedSettings
label={'Enable progress bar?'}
initialValue={
progressBar?.isEnabled ?? defaultTheme.general.progressBar.isEnabled
}
onCheckChange={updateEnabled}
>
<DropdownList
size="sm"
direction="row"
label="Placement:"
currentItem={
progressBar?.placement ?? defaultTheme.general.progressBar.placement
}
onItemSelect={updatePlacement}
items={progressBarPlacements}
/>
<DropdownList
size="sm"
direction="row"
label="Position when embedded:"
moreInfoTooltip='Select "fixed" to always position the progress bar at the top of the window even though your bot is embedded. Select "absolute" to position the progress bar at the top of the chat container.'
currentItem={
progressBar?.position ?? defaultTheme.general.progressBar.position
}
onItemSelect={updatePosition}
items={progressBarPositions}
/>
<HStack justifyContent="space-between">
<Text>Color:</Text>
<ColorPicker
defaultValue={
progressBar?.color ?? defaultTheme.general.progressBar.color
}
onColorChange={updateColor}
/>
</HStack>
<HStack justifyContent="space-between">
<Text>Background color:</Text>
<ColorPicker
defaultValue={
progressBar?.backgroundColor ??
defaultTheme.general.progressBar.backgroundColor
}
onColorChange={updateBackgroundColor}
/>
</HStack>
<HStack justifyContent="space-between">
<Text>Thickness:</Text>
<NumberInput
withVariableButton={false}
defaultValue={
progressBar?.thickness ?? defaultTheme.general.progressBar.thickness
}
onValueChange={updateThickness}
size="sm"
/>
</HStack>
</SwitchWithRelatedSettings>
)
}

View File

@ -8,6 +8,7 @@ import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
import { isDefined, isNotDefined } from '@typebot.io/lib/utils'
import { z } from 'zod'
import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs'
import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress'
export const continueChat = publicProcedure
.meta({
@ -93,6 +94,12 @@ export const continueChat = publicProcedure
const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId)
const isEnded =
newSessionState.progressMetadata &&
!input?.id &&
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ??
0) === 0
return {
messages,
input,
@ -100,5 +107,14 @@ export const continueChat = publicProcedure
dynamicTheme: parseDynamicTheme(newSessionState),
logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs),
lastMessageNewFormat,
progress: newSessionState.progressMetadata
? isEnded
? 100
: computeCurrentProgress({
typebotsQueue: newSessionState.typebotsQueue,
progressMetadata: newSessionState.progressMetadata,
currentInputBlockId: input?.id as string,
})
: undefined,
}
})

View File

@ -96,6 +96,7 @@ export const startChat = publicProcedure
dynamicTheme,
logs: logs?.filter(filterPotentiallySensitiveLogs),
clientSideActions,
progress: newSessionState.progressMetadata ? 0 : undefined,
}
}
)

View File

@ -83,6 +83,7 @@ export const startChatPreview = publicProcedure
dynamicTheme,
logs,
clientSideActions,
progress: newSessionState.progressMetadata ? 0 : undefined,
}
}
)

View File

@ -0,0 +1,130 @@
import { blockHasItems, isDefined, isInputBlock, byId } from '@typebot.io/lib'
import { getBlockById } from '@typebot.io/lib/getBlockById'
import { Block, SessionState } from '@typebot.io/schemas'
type Props = {
typebotsQueue: SessionState['typebotsQueue']
progressMetadata: NonNullable<SessionState['progressMetadata']>
currentInputBlockId: string
}
export const computeCurrentProgress = ({
typebotsQueue,
progressMetadata,
currentInputBlockId,
}: Props) => {
if (progressMetadata.totalAnswers === 0) return 0
const paths = computePossibleNextInputBlocks({
typebotsQueue: typebotsQueue,
blockId: currentInputBlockId,
visitedBlocks: {
[typebotsQueue[0].typebot.id]: [],
},
currentPath: [],
})
return (
(progressMetadata.totalAnswers /
(Math.max(...paths.map((b) => b.length)) +
progressMetadata.totalAnswers)) *
100
)
}
const computePossibleNextInputBlocks = ({
currentPath,
typebotsQueue,
blockId,
visitedBlocks,
}: {
currentPath: string[]
typebotsQueue: SessionState['typebotsQueue']
blockId: string
visitedBlocks: {
[key: string]: string[]
}
}): string[][] => {
if (visitedBlocks[typebotsQueue[0].typebot.id].includes(blockId)) return []
visitedBlocks[typebotsQueue[0].typebot.id].push(blockId)
const possibleNextInputBlocks: string[][] = []
const { block, group, blockIndex } = getBlockById(
blockId,
typebotsQueue[0].typebot.groups
)
if (isInputBlock(block)) currentPath.push(block.id)
const outgoingEdgeIds = getBlockOutgoingEdgeIds(block)
for (const outgoingEdgeId of outgoingEdgeIds) {
const to = typebotsQueue[0].typebot.edges.find(
(e) => e.id === outgoingEdgeId
)?.to
if (!to) continue
const blockId =
to.blockId ??
typebotsQueue[0].typebot.groups.find((g) => g.id === to.groupId)
?.blocks[0].id
if (!blockId) continue
possibleNextInputBlocks.push(
...computePossibleNextInputBlocks({
typebotsQueue,
blockId,
visitedBlocks,
currentPath,
})
)
}
for (const block of group.blocks.slice(blockIndex + 1)) {
possibleNextInputBlocks.push(
...computePossibleNextInputBlocks({
typebotsQueue,
blockId: block.id,
visitedBlocks,
currentPath,
})
)
}
if (outgoingEdgeIds.length > 0 || group.blocks.length !== blockIndex + 1)
return possibleNextInputBlocks
if (typebotsQueue.length > 1) {
const nextEdgeId = typebotsQueue[0].edgeIdToTriggerWhenDone
const to = typebotsQueue[1].typebot.edges.find(byId(nextEdgeId))?.to
if (!to) return possibleNextInputBlocks
const blockId =
to.blockId ??
typebotsQueue[0].typebot.groups.find((g) => g.id === to.groupId)
?.blocks[0].id
if (blockId) {
possibleNextInputBlocks.push(
...computePossibleNextInputBlocks({
typebotsQueue: typebotsQueue.slice(1),
blockId,
visitedBlocks: {
...visitedBlocks,
[typebotsQueue[1].typebot.id]: [],
},
currentPath,
})
)
}
}
possibleNextInputBlocks.push(currentPath)
return possibleNextInputBlocks
}
const getBlockOutgoingEdgeIds = (block: Block) => {
const edgeIds: string[] = []
if (blockHasItems(block)) {
edgeIds.push(...block.items.map((i) => i.outgoingEdgeId).filter(isDefined))
}
if (block.outgoingEdgeId) edgeIds.push(block.outgoingEdgeId)
return edgeIds
}

View File

@ -357,6 +357,9 @@ const setNewAnswerInState =
return {
...state,
progressMetadata: state.progressMetadata
? { totalAnswers: state.progressMetadata.totalAnswers + 1 }
: undefined,
typebotsQueue: state.typebotsQueue.map((typebot, index) =>
index === 0
? {

View File

@ -70,6 +70,13 @@ export const getNextGroup =
...state.typebotsQueue.slice(2),
],
} satisfies SessionState
if (state.progressMetadata)
newSessionState.progressMetadata = {
...state.progressMetadata,
totalAnswers:
state.progressMetadata.totalAnswers +
state.typebotsQueue[0].answers.length,
}
const nextGroup = await getNextGroup(newSessionState)(nextEdgeId)
newSessionState = nextGroup.newSessionState
if (!nextGroup)

View File

@ -137,6 +137,11 @@ export const startSession = async ({
startParams.type === 'preview'
? undefined
: typebot.settings.security?.allowedOrigins,
progressMetadata: initialSessionState?.whatsApp
? undefined
: typebot.theme.general?.progressBar?.isEnabled
? { totalAnswers: 0 }
: undefined,
...initialSessionState,
}

View File

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

View File

@ -31,6 +31,13 @@
--typebot-border-radius: 6px;
--typebot-progress-bar-position: fixed;
--typebot-progress-bar-bg-color: #f7f8ff;
--typebot-progress-bar-color: #0042da;
--typebot-progress-bar-height: 6px;
--typebot-progress-bar-top: 0;
--typebot-progress-bar-bottom: auto;
/* Phone input */
--PhoneInputCountryFlag-borderColor: transparent;
--PhoneInput-color--focus: transparent;
@ -400,3 +407,21 @@ select option {
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
}
.typebot-progress-bar-container {
background-color: var(--typebot-progress-bar-bg-color);
height: var(--typebot-progress-bar-height);
position: var(--typebot-progress-bar-position);
top: var(--typebot-progress-bar-top);
bottom: var(--typebot-progress-bar-bottom);
left: 0;
width: 100%;
z-index: 42424242;
}
.typebot-progress-bar-container > .typebot-progress-bar {
background-color: var(--typebot-progress-bar-color);
position: absolute;
height: 100%;
transition: width 0.25s ease;
}

View File

@ -1,6 +1,6 @@
import { LiteBadge } from './LiteBadge'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { isNotDefined, isNotEmpty } from '@typebot.io/lib'
import { isDefined, isNotDefined, isNotEmpty } from '@typebot.io/lib'
import { startChatQuery } from '@/queries/startChatQuery'
import { ConversationContainer } from './ConversationContainer'
import { setIsMobile } from '@/utils/isMobileSignal'
@ -18,6 +18,7 @@ import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constan
import { clsx } from 'clsx'
import { HTTPError } from 'ky'
import { injectFont } from '@/utils/injectFont'
import { ProgressBar } from './ProgressBar'
export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -129,6 +130,14 @@ export const Bot = (props: BotProps & { class?: string }) => {
createEffect(() => {
if (isNotDefined(props.typebot) || typeof props.typebot === 'string') return
setCustomCss(props.typebot.theme.customCss ?? '')
if (
props.typebot.theme.general?.progressBar?.isEnabled &&
initialChatReply() &&
!initialChatReply()?.typebot.theme.general?.progressBar?.isEnabled
) {
setIsInitialized(false)
initializeBot().then()
}
})
onCleanup(() => {
@ -190,6 +199,9 @@ type BotContentProps = {
}
const BotContent = (props: BotContentProps) => {
const [progressValue, setProgressValue] = createSignal<number | undefined>(
props.initialChatReply.progress
)
let botContainer: HTMLDivElement | undefined
const resizeObserver = new ResizeObserver((entries) => {
@ -207,7 +219,11 @@ const BotContent = (props: BotContentProps) => {
defaultTheme.general.font
)
if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
setCssVariablesValue(
props.initialChatReply.typebot.theme,
botContainer,
props.context.isPreview
)
})
onCleanup(() => {
@ -223,6 +239,14 @@ const BotContent = (props: BotContentProps) => {
props.class
)}
>
<Show
when={
isDefined(progressValue()) &&
props.initialChatReply.typebot.theme.general?.progressBar?.isEnabled
}
>
<ProgressBar value={progressValue() as number} />
</Show>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
@ -231,6 +255,7 @@ const BotContent = (props: BotContentProps) => {
onAnswer={props.onAnswer}
onEnd={props.onEnd}
onNewLogs={props.onNewLogs}
onProgressUpdate={setProgressValue}
/>
</div>
<Show

View File

@ -64,6 +64,7 @@ type Props = {
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void
onProgressUpdate?: (progress: number) => void
}
export const ConversationContainer = (props: Props) => {
@ -172,6 +173,7 @@ export const ConversationContainer = (props: Props) => {
return
}
if (!data) return
if (data.progress) props.onProgressUpdate?.(data.progress)
if (data.lastMessageNewFormat) {
setFormattedMessages([
...formattedMessages(),
@ -269,7 +271,7 @@ export const ConversationContainer = (props: Props) => {
return (
<div
ref={chatContainer}
class="flex flex-col overflow-y-scroll w-full min-h-full px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth gap-2"
class="flex flex-col overflow-y-auto w-full min-h-full px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth gap-2"
>
<For each={chatChunks()}>
{(chatChunk, index) => (

View File

@ -0,0 +1,14 @@
type Props = {
value: number
}
export const ProgressBar = (props: Props) => (
<div class="typebot-progress-bar-container">
<div
class="typebot-progress-bar"
style={{
width: `${props.value}%`,
}}
/>
</div>
)

View File

@ -19,6 +19,14 @@ const cssVariableNames = {
bgColor: '--typebot-container-bg-color',
fontFamily: '--typebot-container-font-family',
color: '--typebot-container-color',
progressBar: {
position: '--typebot-progress-bar-position',
color: '--typebot-progress-bar-color',
backgroundColor: '--typebot-progress-bar-bg-color',
height: '--typebot-progress-bar-height',
top: '--typebot-progress-bar-top',
bottom: '--typebot-progress-bar-bottom',
},
},
chat: {
hostBubbles: {
@ -49,18 +57,24 @@ const cssVariableNames = {
export const setCssVariablesValue = (
theme: Theme | undefined,
container: HTMLDivElement
container: HTMLDivElement,
isPreview?: boolean
) => {
if (!theme) return
const documentStyle = container?.style
if (!documentStyle) return
setGeneralTheme(theme.general ?? defaultTheme.general, documentStyle)
setGeneralTheme(
theme.general ?? defaultTheme.general,
documentStyle,
isPreview
)
setChatTheme(theme.chat ?? defaultTheme.chat, documentStyle)
}
const setGeneralTheme = (
generalTheme: GeneralTheme,
documentStyle: CSSStyleDeclaration
documentStyle: CSSStyleDeclaration,
isPreview?: boolean
) => {
setTypebotBackground(
generalTheme.background ?? defaultTheme.general.background,
@ -72,6 +86,51 @@ const setGeneralTheme = (
? generalTheme.font
: generalTheme.font?.family) ?? defaultTheme.general.font.family
)
setProgressBar(
generalTheme.progressBar ?? defaultTheme.general.progressBar,
documentStyle,
isPreview
)
}
const setProgressBar = (
progressBar: NonNullable<GeneralTheme['progressBar']>,
documentStyle: CSSStyleDeclaration,
isPreview?: boolean
) => {
const position =
progressBar.position ?? defaultTheme.general.progressBar.position
documentStyle.setProperty(
cssVariableNames.general.progressBar.position,
position === 'fixed' ? (isPreview ? 'absolute' : 'fixed') : position
)
documentStyle.setProperty(
cssVariableNames.general.progressBar.color,
progressBar.color ?? defaultTheme.general.progressBar.color
)
documentStyle.setProperty(
cssVariableNames.general.progressBar.backgroundColor,
progressBar.backgroundColor ??
defaultTheme.general.progressBar.backgroundColor
)
documentStyle.setProperty(
cssVariableNames.general.progressBar.height,
`${progressBar.thickness ?? defaultTheme.general.progressBar.thickness}px`
)
const placement =
progressBar.placement ?? defaultTheme.general.progressBar.placement
documentStyle.setProperty(
cssVariableNames.general.progressBar.top,
placement === 'Top' ? '0' : 'auto'
)
documentStyle.setProperty(
cssVariableNames.general.progressBar.bottom,
placement === 'Bottom' ? '0' : 'auto'
)
}
const setChatTheme = (

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/nextjs",
"version": "0.2.42",
"version": "0.2.43",
"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.42",
"version": "0.2.43",
"description": "Convenient library to display typebots on your React app",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -310,6 +310,12 @@ const chatResponseBaseSchema = z.object({
.describe(
'If the typebot contains dynamic avatars, dynamicTheme returns the new avatar URLs whenever their variables are updated.'
),
progress: z
.number()
.optional()
.describe(
'If progress bar is enabled, this field will return a number between 0 and 100 indicating the current progress based on the longest remaining path of the flow.'
),
})
export const startChatResponseSchema = z

View File

@ -81,6 +81,11 @@ const sessionStateSchemaV2 = z.object({
.describe('Expiry timeout in milliseconds'),
typingEmulation: settingsSchema.shape.typingEmulation.optional(),
currentVisitedEdgeIndex: z.number().optional(),
progressMetadata: z
.object({
totalAnswers: z.number(),
})
.optional(),
})
const sessionStateSchemaV3 = sessionStateSchemaV2

View File

@ -8,6 +8,9 @@ export enum BackgroundType {
export const fontTypes = ['Google', 'Custom'] as const
export const progressBarPlacements = ['Top', 'Bottom'] as const
export const progressBarPositions = ['fixed', 'absolute'] as const
export const defaultTheme = {
chat: {
roundness: 'medium',
@ -32,5 +35,13 @@ export const defaultTheme = {
family: 'Open Sans',
},
background: { type: BackgroundType.COLOR, content: '#ffffff' },
progressBar: {
isEnabled: false,
color: '#0042DA',
backgroundColor: '#e0edff',
thickness: 4,
position: 'fixed',
placement: 'Top',
},
},
} as const satisfies Theme

View File

@ -1,6 +1,11 @@
import { ThemeTemplate as ThemeTemplatePrisma } from '@typebot.io/prisma'
import { z } from '../../../zod'
import { BackgroundType, fontTypes } from './constants'
import {
BackgroundType,
fontTypes,
progressBarPlacements,
progressBarPositions,
} from './constants'
const avatarPropsSchema = z.object({
isEnabled: z.boolean().optional(),
@ -51,9 +56,20 @@ export const fontSchema = z
.or(z.discriminatedUnion('type', [googleFontSchema, customFontSchema]))
export type Font = z.infer<typeof fontSchema>
const progressBarSchema = z.object({
isEnabled: z.boolean().optional(),
color: z.string().optional(),
backgroundColor: z.string().optional(),
placement: z.enum(progressBarPlacements).optional(),
thickness: z.number().optional(),
position: z.enum(progressBarPositions).optional(),
})
export type ProgressBar = z.infer<typeof progressBarSchema>
const generalThemeSchema = z.object({
font: fontSchema.optional(),
background: backgroundSchema.optional(),
progressBar: progressBarSchema.optional(),
})
export const themeSchema = z