✨ (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:
130
packages/bot-engine/computeCurrentProgress.ts
Normal file
130
packages/bot-engine/computeCurrentProgress.ts
Normal 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
|
||||
}
|
@ -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
|
||||
? {
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) => (
|
||||
|
14
packages/embeds/js/src/components/ProgressBar.tsx
Normal file
14
packages/embeds/js/src/components/ProgressBar.tsx
Normal 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>
|
||||
)
|
@ -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 = (
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user