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

@ -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