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, Box,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import React, { ChangeEvent, useState } from 'react' import React, { useState } from 'react'
import tinyColor from 'tinycolor2' import tinyColor from 'tinycolor2'
import { useDebouncedCallback } from 'use-debounce'
const colorsSelection: `#${string}`[] = [ const colorsSelection: `#${string}`[] = [
'#666460', '#666460',
@@ -42,9 +43,9 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
const [color, setColor] = useState(defaultValue ?? '') const [color, setColor] = useState(defaultValue ?? '')
const displayedValue = value ?? color const displayedValue = value ?? color
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) => { const handleColorChange = (color: string) => {
setColor(e.target.value) setColor(color)
onColorChange(e.target.value) onColorChange(color)
} }
const handleClick = (color: string) => () => { const handleClick = (color: string) => () => {
@@ -103,7 +104,7 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
aria-label={t('colorPicker.colorValue.ariaLabel')} aria-label={t('colorPicker.colorValue.ariaLabel')}
size="sm" size="sm"
value={displayedValue} value={displayedValue}
onChange={handleColorChange} onChange={(e) => handleColorChange(e.target.value)}
/> />
<NativeColorPicker <NativeColorPicker
size="sm" size="sm"
@@ -124,8 +125,12 @@ const NativeColorPicker = ({
...props ...props
}: { }: {
color: string color: string
onColorChange: (e: ChangeEvent<HTMLInputElement>) => void onColorChange: (color: string) => void
} & ButtonProps) => { } & ButtonProps) => {
const debouncedOnColorChange = useDebouncedCallback((color: string) => {
onColorChange(color)
}, 200)
return ( return (
<> <>
<Button as="label" htmlFor="native-picker" {...props}> <Button as="label" htmlFor="native-picker" {...props}>
@@ -136,7 +141,7 @@ const NativeColorPicker = ({
display="none" display="none"
id="native-picker" id="native-picker"
value={color} 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 { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage' import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { headerHeight } from '@/features/editor/constants'
export const SettingsPage = () => { export const SettingsPage = () => {
const { typebot, is404 } = useTypebot() const { typebot, is404 } = useTypebot()
@@ -15,7 +16,7 @@ export const SettingsPage = () => {
<Flex overflow="hidden" h="100vh" flexDir="column"> <Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Settings` : 'Settings'} /> <Seo title={typebot?.name ? `${typebot.name} | Settings` : 'Settings'} />
<TypebotHeader /> <TypebotHeader />
<Flex h="full" w="full"> <Flex height={`calc(100vh - ${headerHeight}px)`} w="full">
<SettingsSideMenu /> <SettingsSideMenu />
<Flex flex="1"> <Flex flex="1">
{typebot && ( {typebot && (

View File

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

View File

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

View File

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

View File

@@ -34,56 +34,61 @@ export const BackgroundContent = ({
const handleContentChange = (content: string) => const handleContentChange = (content: string) =>
onBackgroundContentChange(content) onBackgroundContentChange(content)
switch (background?.type) { if (
case BackgroundType.COLOR: (background?.type ?? defaultTheme.general.background.type) ===
return ( BackgroundType.IMAGE
<Flex justify="space-between" align="center"> ) {
<Text>{t('theme.sideMenu.global.background.color')}</Text> if (!typebot) return null
<ColorPicker return (
value={ <Popover isLazy placement="top">
background.content ?? defaultTheme.general.background.content <PopoverTrigger>
} {isNotEmpty(background?.content) ? (
onColorChange={handleContentChange} <Image
/> src={background?.content}
</Flex> alt={t('theme.sideMenu.global.background.image.alt')}
) cursor="pointer"
case BackgroundType.IMAGE: _hover={{ filter: 'brightness(.9)' }}
if (!typebot) return null transition="filter 200ms"
return ( rounded="md"
<Popover isLazy placement="top"> maxH="200px"
<PopoverTrigger> objectFit="cover"
{isNotEmpty(background.content) ? ( />
<Image ) : (
src={background.content} <Button>
alt={t('theme.sideMenu.global.background.image.alt')} {t('theme.sideMenu.global.background.image.button')}
cursor="pointer" </Button>
_hover={{ filter: 'brightness(.9)' }} )}
transition="filter 200ms" </PopoverTrigger>
rounded="md" <Portal>
maxH="200px" <PopoverContent p="4" w="500px">
objectFit="cover" <ImageUploadContent
/> uploadFileProps={{
) : ( workspaceId: typebot.workspaceId,
<Button> typebotId: typebot.id,
{t('theme.sideMenu.global.background.image.button')} fileName: 'background',
</Button> }}
)} defaultUrl={background?.content}
</PopoverTrigger> onSubmit={handleContentChange}
<Portal> excludedTabs={['giphy', 'icon']}
<PopoverContent p="4" w="500px"> />
<ImageUploadContent </PopoverContent>
uploadFileProps={{ </Portal>
workspaceId: typebot.workspaceId, </Popover>
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 { Background } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { BackgroundContent } from './BackgroundContent' 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' import { useTranslate } from '@tolgee/react'
type Props = { type Props = {
@@ -11,8 +14,6 @@ type Props = {
onBackgroundChange: (newBackground: Background) => void onBackgroundChange: (newBackground: Background) => void
} }
const defaultBackgroundType = BackgroundType.NONE
export const BackgroundSelector = ({ export const BackgroundSelector = ({
background, background,
onBackgroundChange, onBackgroundChange,
@@ -20,11 +21,10 @@ export const BackgroundSelector = ({
const { t } = useTranslate() const { t } = useTranslate()
const handleBackgroundTypeChange = (type: BackgroundType) => const handleBackgroundTypeChange = (type: BackgroundType) =>
background &&
onBackgroundChange({ ...background, type, content: undefined }) onBackgroundChange({ ...background, type, content: undefined })
const handleBackgroundContentChange = (content: string) => const handleBackgroundContentChange = (content: string) =>
background && onBackgroundChange({ ...background, content }) onBackgroundChange({ ...background, content })
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
@@ -44,7 +44,7 @@ export const BackgroundSelector = ({
value: BackgroundType.NONE, value: BackgroundType.NONE,
}, },
]} ]}
value={background?.type ?? defaultBackgroundType} value={background?.type ?? defaultTheme.general.background.type}
onSelect={handleBackgroundTypeChange} onSelect={handleBackgroundTypeChange}
/> />
<BackgroundContent <BackgroundContent

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
import { Select } from '@/components/inputs/Select' import { Select } from '@/components/inputs/Select'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { GoogleFont } from '@typebot.io/schemas' import { GoogleFont } from '@typebot.io/schemas'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
type Props = { type Props = {
font: GoogleFont | string font: GoogleFont | string | undefined
onFontChange: (font: GoogleFont) => void onFontChange: (font: GoogleFont) => void
} }
export const GoogleFontForm = ({ font, onFontChange }: Props) => { export const GoogleFontForm = ({ font, onFontChange }: Props) => {
const [currentFont, setCurrentFont] = useState( 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[]>([]) 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 { isDefined, isNotDefined } from '@typebot.io/lib/utils'
import { z } from 'zod' import { z } from 'zod'
import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs' import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs'
import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress'
export const continueChat = publicProcedure export const continueChat = publicProcedure
.meta({ .meta({
@@ -93,6 +94,12 @@ export const continueChat = publicProcedure
const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId) const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId)
const isEnded =
newSessionState.progressMetadata &&
!input?.id &&
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ??
0) === 0
return { return {
messages, messages,
input, input,
@@ -100,5 +107,14 @@ export const continueChat = publicProcedure
dynamicTheme: parseDynamicTheme(newSessionState), dynamicTheme: parseDynamicTheme(newSessionState),
logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs), logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs),
lastMessageNewFormat, 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, dynamicTheme,
logs: logs?.filter(filterPotentiallySensitiveLogs), logs: logs?.filter(filterPotentiallySensitiveLogs),
clientSideActions, clientSideActions,
progress: newSessionState.progressMetadata ? 0 : undefined,
} }
} }
) )

View File

@@ -83,6 +83,7 @@ export const startChatPreview = publicProcedure
dynamicTheme, dynamicTheme,
logs, logs,
clientSideActions, 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 { return {
...state, ...state,
progressMetadata: state.progressMetadata
? { totalAnswers: state.progressMetadata.totalAnswers + 1 }
: undefined,
typebotsQueue: state.typebotsQueue.map((typebot, index) => typebotsQueue: state.typebotsQueue.map((typebot, index) =>
index === 0 index === 0
? { ? {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/js", "name": "@typebot.io/js",
"version": "0.2.42", "version": "0.2.43",
"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

@@ -31,6 +31,13 @@
--typebot-border-radius: 6px; --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 */ /* Phone input */
--PhoneInputCountryFlag-borderColor: transparent; --PhoneInputCountryFlag-borderColor: transparent;
--PhoneInput-color--focus: transparent; --PhoneInput-color--focus: transparent;
@@ -400,3 +407,21 @@ select option {
color: var(--typebot-input-color); color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-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 { LiteBadge } from './LiteBadge'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js' 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 { startChatQuery } from '@/queries/startChatQuery'
import { ConversationContainer } from './ConversationContainer' import { ConversationContainer } from './ConversationContainer'
import { setIsMobile } from '@/utils/isMobileSignal' import { setIsMobile } from '@/utils/isMobileSignal'
@@ -18,6 +18,7 @@ import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constan
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { HTTPError } from 'ky' import { HTTPError } from 'ky'
import { injectFont } from '@/utils/injectFont' import { injectFont } from '@/utils/injectFont'
import { ProgressBar } from './ProgressBar'
export type BotProps = { export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -129,6 +130,14 @@ export const Bot = (props: BotProps & { class?: string }) => {
createEffect(() => { createEffect(() => {
if (isNotDefined(props.typebot) || typeof props.typebot === 'string') return if (isNotDefined(props.typebot) || typeof props.typebot === 'string') return
setCustomCss(props.typebot.theme.customCss ?? '') setCustomCss(props.typebot.theme.customCss ?? '')
if (
props.typebot.theme.general?.progressBar?.isEnabled &&
initialChatReply() &&
!initialChatReply()?.typebot.theme.general?.progressBar?.isEnabled
) {
setIsInitialized(false)
initializeBot().then()
}
}) })
onCleanup(() => { onCleanup(() => {
@@ -190,6 +199,9 @@ type BotContentProps = {
} }
const BotContent = (props: BotContentProps) => { const BotContent = (props: BotContentProps) => {
const [progressValue, setProgressValue] = createSignal<number | undefined>(
props.initialChatReply.progress
)
let botContainer: HTMLDivElement | undefined let botContainer: HTMLDivElement | undefined
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
@@ -207,7 +219,11 @@ const BotContent = (props: BotContentProps) => {
defaultTheme.general.font defaultTheme.general.font
) )
if (!botContainer) return if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer) setCssVariablesValue(
props.initialChatReply.typebot.theme,
botContainer,
props.context.isPreview
)
}) })
onCleanup(() => { onCleanup(() => {
@@ -223,6 +239,14 @@ const BotContent = (props: BotContentProps) => {
props.class 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"> <div class="flex w-full h-full justify-center">
<ConversationContainer <ConversationContainer
context={props.context} context={props.context}
@@ -231,6 +255,7 @@ const BotContent = (props: BotContentProps) => {
onAnswer={props.onAnswer} onAnswer={props.onAnswer}
onEnd={props.onEnd} onEnd={props.onEnd}
onNewLogs={props.onNewLogs} onNewLogs={props.onNewLogs}
onProgressUpdate={setProgressValue}
/> />
</div> </div>
<Show <Show

View File

@@ -64,6 +64,7 @@ type Props = {
onAnswer?: (answer: { message: string; blockId: string }) => void onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void onEnd?: () => void
onNewLogs?: (logs: OutgoingLog[]) => void onNewLogs?: (logs: OutgoingLog[]) => void
onProgressUpdate?: (progress: number) => void
} }
export const ConversationContainer = (props: Props) => { export const ConversationContainer = (props: Props) => {
@@ -172,6 +173,7 @@ export const ConversationContainer = (props: Props) => {
return return
} }
if (!data) return if (!data) return
if (data.progress) props.onProgressUpdate?.(data.progress)
if (data.lastMessageNewFormat) { if (data.lastMessageNewFormat) {
setFormattedMessages([ setFormattedMessages([
...formattedMessages(), ...formattedMessages(),
@@ -269,7 +271,7 @@ export const ConversationContainer = (props: Props) => {
return ( return (
<div <div
ref={chatContainer} 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()}> <For each={chatChunks()}>
{(chatChunk, index) => ( {(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', bgColor: '--typebot-container-bg-color',
fontFamily: '--typebot-container-font-family', fontFamily: '--typebot-container-font-family',
color: '--typebot-container-color', 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: { chat: {
hostBubbles: { hostBubbles: {
@@ -49,18 +57,24 @@ const cssVariableNames = {
export const setCssVariablesValue = ( export const setCssVariablesValue = (
theme: Theme | undefined, theme: Theme | undefined,
container: HTMLDivElement container: HTMLDivElement,
isPreview?: boolean
) => { ) => {
if (!theme) return if (!theme) return
const documentStyle = container?.style const documentStyle = container?.style
if (!documentStyle) return if (!documentStyle) return
setGeneralTheme(theme.general ?? defaultTheme.general, documentStyle) setGeneralTheme(
theme.general ?? defaultTheme.general,
documentStyle,
isPreview
)
setChatTheme(theme.chat ?? defaultTheme.chat, documentStyle) setChatTheme(theme.chat ?? defaultTheme.chat, documentStyle)
} }
const setGeneralTheme = ( const setGeneralTheme = (
generalTheme: GeneralTheme, generalTheme: GeneralTheme,
documentStyle: CSSStyleDeclaration documentStyle: CSSStyleDeclaration,
isPreview?: boolean
) => { ) => {
setTypebotBackground( setTypebotBackground(
generalTheme.background ?? defaultTheme.general.background, generalTheme.background ?? defaultTheme.general.background,
@@ -72,6 +86,51 @@ const setGeneralTheme = (
? generalTheme.font ? generalTheme.font
: generalTheme.font?.family) ?? defaultTheme.general.font.family : 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 = ( const setChatTheme = (

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/nextjs", "name": "@typebot.io/nextjs",
"version": "0.2.42", "version": "0.2.43",
"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.42", "version": "0.2.43",
"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",

View File

@@ -310,6 +310,12 @@ const chatResponseBaseSchema = z.object({
.describe( .describe(
'If the typebot contains dynamic avatars, dynamicTheme returns the new avatar URLs whenever their variables are updated.' '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 export const startChatResponseSchema = z

View File

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

View File

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

View File

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