🧰 Aggregate utils & set up results collection in viewer
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"db": "*",
|
||||
"fast-equals": "^2.0.4",
|
||||
"react-frame-component": "^5.2.1",
|
||||
"react-scroll": "^1.8.4",
|
||||
"react-transition-group": "^4.4.2"
|
||||
@@ -15,18 +16,18 @@
|
||||
"@rollup/plugin-commonjs": "^21.0.1",
|
||||
"@rollup/plugin-node-resolve": "^13.1.1",
|
||||
"@rollup/plugin-typescript": "^8.3.0",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react": "^17.0.38",
|
||||
"@types/react-scroll": "^1.8.3",
|
||||
"@types/react-transition-group": "^4.4.4",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.5",
|
||||
"rollup": "^2.61.1",
|
||||
"rollup-plugin-dts": "^4.0.1",
|
||||
"rollup": "^2.62.0",
|
||||
"rollup-plugin-dts": "^4.1.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"tailwindcss": "^3.0.7",
|
||||
"tailwindcss": "^3.0.8",
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
|
||||
|
||||
type ChatBlockProps = {
|
||||
block: Block
|
||||
onBlockEnd: (nextBlockId: string) => void
|
||||
onBlockEnd: (nextBlockId?: string) => void
|
||||
}
|
||||
|
||||
export const ChatBlock = ({ block, onBlockEnd }: ChatBlockProps) => {
|
||||
@@ -31,7 +31,10 @@ export const ChatBlock = ({ block, onBlockEnd }: ChatBlockProps) => {
|
||||
|
||||
const displayNextStep = () => {
|
||||
const currentStep = [...displayedSteps].pop()
|
||||
if (currentStep?.target?.blockId)
|
||||
if (
|
||||
currentStep?.target?.blockId ||
|
||||
displayedSteps.length === block.steps.length
|
||||
)
|
||||
return onBlockEnd(currentStep?.target?.blockId)
|
||||
const nextStep = block.steps[displayedSteps.length]
|
||||
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useAnswers } from '../../../contexts/AnswersContext'
|
||||
import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
|
||||
import { Step } from '../../../models'
|
||||
import { isTextInputStep, isTextStep } from '../../../services/utils'
|
||||
@@ -13,13 +14,21 @@ export const ChatStep = ({
|
||||
step: Step
|
||||
onTransitionEnd: () => void
|
||||
}) => {
|
||||
const { addAnswer } = useAnswers()
|
||||
|
||||
const handleInputSubmit = (content: string) => {
|
||||
addAnswer({ stepId: step.id, blockId: step.blockId, content })
|
||||
onTransitionEnd()
|
||||
}
|
||||
|
||||
if (isTextStep(step))
|
||||
return <HostMessageBubble step={step} onTransitionEnd={onTransitionEnd} />
|
||||
if (isTextInputStep(step)) return <InputChatStep onSubmit={onTransitionEnd} />
|
||||
if (isTextInputStep(step))
|
||||
return <InputChatStep onSubmit={handleInputSubmit} />
|
||||
return <span>No step</span>
|
||||
}
|
||||
|
||||
const InputChatStep = ({ onSubmit }: { onSubmit: () => void }) => {
|
||||
const InputChatStep = ({ onSubmit }: { onSubmit: (value: string) => void }) => {
|
||||
const { addNewAvatarOffset } = useHostAvatars()
|
||||
const [answer, setAnswer] = useState<string>()
|
||||
|
||||
@@ -29,7 +38,7 @@ const InputChatStep = ({ onSubmit }: { onSubmit: () => void }) => {
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
setAnswer(value)
|
||||
onSubmit()
|
||||
onSubmit(value)
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { PublicTypebot } from '..'
|
||||
import { Answer, PublicTypebot } from '..'
|
||||
|
||||
import { Block } from '..'
|
||||
import { ChatBlock } from './ChatBlock/ChatBlock'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { setCssVariablesValue } from '../services/theme'
|
||||
import { useAnswers } from '../contexts/AnswersContext'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
type Props = {
|
||||
typebot: PublicTypebot
|
||||
onNewBlockVisible: (blockId: string) => void
|
||||
onAnswersUpdate: (answers: Answer[]) => void
|
||||
onCompleted: () => void
|
||||
}
|
||||
export const ConversationContainer = ({
|
||||
typebot,
|
||||
onNewBlockVisisble,
|
||||
}: {
|
||||
typebot: PublicTypebot
|
||||
onNewBlockVisisble: (blockId: string) => void
|
||||
}) => {
|
||||
onNewBlockVisible,
|
||||
onAnswersUpdate,
|
||||
onCompleted,
|
||||
}: Props) => {
|
||||
const { document: frameDocument } = useFrame()
|
||||
const [displayedBlocks, setDisplayedBlocks] = useState<Block[]>([])
|
||||
|
||||
const [isConversationEnded, setIsConversationEnded] = useState(false)
|
||||
const [localAnswers, setLocalAnswers] = useState<Answer[]>([])
|
||||
const { answers } = useAnswers()
|
||||
const bottomAnchor = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const displayNextBlock = (blockId: string) => {
|
||||
const displayNextBlock = (blockId?: string) => {
|
||||
if (!blockId) return onCompleted()
|
||||
const nextBlock = typebot.blocks.find((b) => b.id === blockId)
|
||||
if (!nextBlock) return
|
||||
onNewBlockVisisble(blockId)
|
||||
if (!nextBlock) return onCompleted()
|
||||
onNewBlockVisible(blockId)
|
||||
setDisplayedBlocks([...displayedBlocks, nextBlock])
|
||||
}
|
||||
|
||||
@@ -35,6 +43,12 @@ export const ConversationContainer = ({
|
||||
setCssVariablesValue(typebot.theme, frameDocument.body.style)
|
||||
}, [typebot.theme, frameDocument])
|
||||
|
||||
useEffect(() => {
|
||||
if (deepEqual(localAnswers, answers)) return
|
||||
setLocalAnswers(answers)
|
||||
onAnswersUpdate(answers)
|
||||
}, [answers])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view"
|
||||
@@ -48,14 +62,7 @@ export const ConversationContainer = ({
|
||||
/>
|
||||
))}
|
||||
{/* We use a block to simulate padding because it makes iOS scroll flicker */}
|
||||
<div
|
||||
className="w-full"
|
||||
ref={bottomAnchor}
|
||||
style={{
|
||||
transition: isConversationEnded ? 'height 1s' : '',
|
||||
height: isConversationEnded ? '5%' : '20%',
|
||||
}}
|
||||
/>
|
||||
<div className="w-full" ref={bottomAnchor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { BackgroundType, PublicTypebot } from '../models'
|
||||
import { Answer, BackgroundType, PublicTypebot } from '../models'
|
||||
import { TypebotContext } from '../contexts/TypebotContext'
|
||||
import Frame from 'react-frame-component'
|
||||
//@ts-ignore
|
||||
import style from '../assets/style.css'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import { ResultContext } from '../contexts/ResultsContext'
|
||||
import { AnswersContext } from '../contexts/AnswersContext'
|
||||
|
||||
export type TypebotViewerProps = {
|
||||
typebot: PublicTypebot
|
||||
onNewBlockVisisble?: (blockId: string) => void
|
||||
onNewBlockVisible?: (blockId: string) => void
|
||||
onAnswersUpdate?: (answers: Answer[]) => void
|
||||
onCompleted?: () => void
|
||||
}
|
||||
export const TypebotViewer = ({
|
||||
typebot,
|
||||
onNewBlockVisisble,
|
||||
onNewBlockVisible,
|
||||
onAnswersUpdate,
|
||||
onCompleted,
|
||||
}: TypebotViewerProps) => {
|
||||
const containerBgColor = useMemo(
|
||||
() =>
|
||||
@@ -23,7 +27,13 @@ export const TypebotViewer = ({
|
||||
[typebot.theme.general.background]
|
||||
)
|
||||
const handleNewBlockVisible = (blockId: string) => {
|
||||
if (onNewBlockVisisble) onNewBlockVisisble(blockId)
|
||||
if (onNewBlockVisible) onNewBlockVisible(blockId)
|
||||
}
|
||||
const handleAnswersUpdate = (answers: Answer[]) => {
|
||||
if (onAnswersUpdate) onAnswersUpdate(answers)
|
||||
}
|
||||
const handleCompleted = () => {
|
||||
if (onCompleted) onCompleted()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -38,22 +48,24 @@ export const TypebotViewer = ({
|
||||
}}
|
||||
/>
|
||||
<TypebotContext typebot={typebot}>
|
||||
<ResultContext typebotId={typebot.id}>
|
||||
<AnswersContext typebotId={typebot.id}>
|
||||
<div
|
||||
className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container"
|
||||
style={{
|
||||
// We set this as inline style to avoid color for SSR
|
||||
// We set this as inline style to avoid color flash for SSR
|
||||
backgroundColor: containerBgColor,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
typebot={typebot}
|
||||
onNewBlockVisisble={handleNewBlockVisible}
|
||||
onNewBlockVisible={handleNewBlockVisible}
|
||||
onAnswersUpdate={handleAnswersUpdate}
|
||||
onCompleted={handleCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResultContext>
|
||||
</AnswersContext>
|
||||
</TypebotContext>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
34
packages/bot-engine/src/contexts/AnswersContext.tsx
Normal file
34
packages/bot-engine/src/contexts/AnswersContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Answer } from '../models'
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||
|
||||
const answersContext = createContext<{
|
||||
answers: Answer[]
|
||||
addAnswer: (answer: Answer) => void
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const AnswersContext = ({
|
||||
children,
|
||||
typebotId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
typebotId: string
|
||||
}) => {
|
||||
const [answers, setAnswers] = useState<Answer[]>([])
|
||||
|
||||
const addAnswer = (answer: Answer) =>
|
||||
setAnswers((answers) => [...answers, answer])
|
||||
|
||||
return (
|
||||
<answersContext.Provider
|
||||
value={{
|
||||
answers,
|
||||
addAnswer,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</answersContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAnswers = () => useContext(answersContext)
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Answer, Result } from '../models'
|
||||
import React, {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const resultContext = createContext<{
|
||||
result: Result
|
||||
setResult: Dispatch<SetStateAction<Result>>
|
||||
addAnswer: (answer: Answer) => void
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const ResultContext = ({
|
||||
children,
|
||||
typebotId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
typebotId: string
|
||||
}) => {
|
||||
const [result, setResult] = useState<Result>({
|
||||
id: 'tmp',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
answers: [],
|
||||
typebotId,
|
||||
isCompleted: false,
|
||||
})
|
||||
|
||||
const addAnswer = (answer: Answer) =>
|
||||
setResult({ ...result, answers: [...result.answers, answer] })
|
||||
|
||||
return (
|
||||
<resultContext.Provider
|
||||
value={{
|
||||
result,
|
||||
setResult,
|
||||
addAnswer,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</resultContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useResult = () => useContext(resultContext)
|
||||
4
packages/utils/apiUtils.ts
Normal file
4
packages/utils/apiUtils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { NextApiResponse } from 'next'
|
||||
|
||||
export const methodNotAllowed = (res: NextApiResponse) =>
|
||||
res.status(405).json({ message: 'Method Not Allowed' })
|
||||
2
packages/utils/index.ts
Normal file
2
packages/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './utils'
|
||||
export * from './apiUtils'
|
||||
13
packages/utils/package.json
Normal file
13
packages/utils/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "utils",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^12.0.7"
|
||||
}
|
||||
}
|
||||
10
packages/utils/tsconfig.json
Normal file
10
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
27
packages/utils/utils.ts
Normal file
27
packages/utils/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const sendRequest = async <ResponseData>({
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
}: {
|
||||
url: string
|
||||
method: string
|
||||
body?: Record<string, unknown>
|
||||
}): Promise<{ data?: ResponseData; error?: Error }> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
mode: 'cors',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!response.ok) throw new Error(response.statusText)
|
||||
const data = await response.json()
|
||||
return { data }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { error: e as Error }
|
||||
}
|
||||
}
|
||||
|
||||
export const isDefined = <T>(value: T | undefined | null): value is T => {
|
||||
return <T>value !== undefined && <T>value !== null
|
||||
}
|
||||
Reference in New Issue
Block a user