Feature: Collect contact information from users inside the chatbot (#1948)

* Add leads settings to chatflow configuration

* Add leads tab to chatflow configuration with options for lead capture

* Add database entity and migrations for leads

* Add endpoint for adding and fetching leads

* Show lead capture form in UI chat window when enabled

* Add view leads dialog

* Make export leads functional

* Add input for configuring message on successful lead capture

* Add migrations for adding lead email in chat message if available

* show lead email in view messages

* ui touch up

* Remove unused code and update how lead email is shown in view messages dialog

* Fix lead not getting saved

* Disable input when lead form is shown and save lead info to localstorage

* Fix lead capture form not working

* disabled lead save button until at least one form field is turned on, get rid of local storage _LEAD

* add leads API to as whitelist public endpoint

* Send leadEmail in internal chat inputs

* Fix condition for disabling input field and related buttons when lead is enabled/disabled and when lead is saved

* update leads ui

* update error message and alter table add column sqlite migration

---------

Co-authored-by: Henry <hzj94@hotmail.com>
This commit is contained in:
Ilango
2024-05-02 06:24:59 +05:30
committed by GitHub
parent adea2f0830
commit db452cd74d
31 changed files with 979 additions and 57 deletions
@@ -10,10 +10,10 @@ const StatsCard = ({ title, stat }) => {
return (
<Card sx={{ border: '1px solid #e0e0e0', borderRadius: `${customization.borderRadius}px` }}>
<CardContent>
<Typography sx={{ fontSize: 14 }} color='text.primary' gutterBottom>
<Typography sx={{ fontSize: '0.875rem' }} color='text.primary' gutterBottom>
{title}
</Typography>
<Typography sx={{ fontSize: 30, fontWeight: 500 }} color='text.primary'>
<Typography sx={{ fontSize: '1.5rem', fontWeight: 500 }} color='text.primary'>
{stat}
</Typography>
</CardContent>
@@ -2,12 +2,14 @@ import PropTypes from 'prop-types'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { Box, Dialog, DialogContent, DialogTitle, Tabs, Tab } from '@mui/material'
import { tabsClasses } from '@mui/material/Tabs'
import SpeechToText from '@/ui-component/extended/SpeechToText'
import RateLimit from '@/ui-component/extended/RateLimit'
import AllowedDomains from '@/ui-component/extended/AllowedDomains'
import ChatFeedback from '@/ui-component/extended/ChatFeedback'
import AnalyseFlow from '@/ui-component/extended/AnalyseFlow'
import StarterPrompts from '@/ui-component/extended/StarterPrompts'
import Leads from '@/ui-component/extended/Leads'
const CHATFLOW_CONFIGURATION_TABS = [
{
@@ -33,6 +35,10 @@ const CHATFLOW_CONFIGURATION_TABS = [
{
label: 'Analyse Chatflow',
id: 'analyseChatflow'
},
{
label: 'Leads',
id: 'leads'
}
]
@@ -83,10 +89,19 @@ const ChatflowConfigurationDialog = ({ show, dialogProps, onCancel }) => {
</DialogTitle>
<DialogContent>
<Tabs
sx={{ position: 'relative', minHeight: '40px', height: '40px' }}
sx={{
position: 'relative',
minHeight: '40px',
height: '40px',
[`& .${tabsClasses.scrollButtons}`]: {
'&.Mui-disabled': { opacity: 0.3 }
}
}}
value={tabValue}
onChange={(event, value) => setTabValue(value)}
aria-label='tabs'
variant='scrollable'
scrollButtons='auto'
>
{CHATFLOW_CONFIGURATION_TABS.map((item, index) => (
<Tab
@@ -105,6 +120,7 @@ const ChatflowConfigurationDialog = ({ show, dialogProps, onCancel }) => {
{item.id === 'chatFeedback' ? <ChatFeedback dialogProps={dialogProps} /> : null}
{item.id === 'allowedDomains' ? <AllowedDomains dialogProps={dialogProps} /> : null}
{item.id === 'analyseChatflow' ? <AnalyseFlow dialogProps={dialogProps} /> : null}
{item.id === 'leads' ? <Leads dialogProps={dialogProps} /> : null}
</TabPanel>
))}
</DialogContent>
@@ -0,0 +1,208 @@
import { createPortal } from 'react-dom'
import { useDispatch } from 'react-redux'
import { useState, useEffect, forwardRef } from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
// material-ui
import {
Button,
ListItemButton,
Dialog,
DialogContent,
DialogTitle,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Stack,
Box,
OutlinedInput
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconFileExport, IconSearch } from '@tabler/icons'
import leadsEmptySVG from '@/assets/images/leads_empty.svg'
// store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
// API
import useApi from '@/hooks/useApi'
import leadsApi from '@/api/lead'
import '@/views/chatmessage/ChatMessage.css'
import 'react-datepicker/dist/react-datepicker.css'
const DatePickerCustomInput = forwardRef(function DatePickerCustomInput({ value, onClick }, ref) {
return (
<ListItemButton style={{ borderRadius: 15, border: '1px solid #e0e0e0' }} onClick={onClick} ref={ref}>
{value}
</ListItemButton>
)
})
DatePickerCustomInput.propTypes = {
value: PropTypes.string,
onClick: PropTypes.func
}
const ViewLeadsDialog = ({ show, dialogProps, onCancel }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()
const theme = useTheme()
const [leads, setLeads] = useState([])
const [search, setSearch] = useState('')
const getLeadsApi = useApi(leadsApi.getLeads)
const onSearchChange = (event) => {
setSearch(event.target.value)
}
function filterLeads(data) {
return (
data.name.toLowerCase().indexOf(search.toLowerCase()) > -1 ||
(data.email && data.email.toLowerCase().indexOf(search.toLowerCase()) > -1) ||
(data.phone && data.phone.toLowerCase().indexOf(search.toLowerCase()) > -1)
)
}
const exportMessages = async () => {
const exportData = {
leads
}
const dataStr = JSON.stringify(exportData, null, 2)
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const exportFileDefaultName = `${dialogProps.chatflow.id}-leads.json`
let linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
}
useEffect(() => {
if (getLeadsApi.data) {
setLeads(getLeadsApi.data)
}
}, [getLeadsApi.data])
useEffect(() => {
if (dialogProps.chatflow) {
getLeadsApi.request(dialogProps.chatflow.id)
}
return () => {
setLeads([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dialogProps])
useEffect(() => {
if (show) dispatch({ type: SHOW_CANVAS_DIALOG })
else dispatch({ type: HIDE_CANVAS_DIALOG })
return () => dispatch({ type: HIDE_CANVAS_DIALOG })
}, [show, dispatch])
const component = show ? (
<Dialog
onClose={onCancel}
open={show}
fullWidth
maxWidth={leads && leads.length == 0 ? 'md' : 'lg'}
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{dialogProps.title}
<OutlinedInput
size='small'
sx={{
ml: 3,
width: '280px',
height: '100%',
display: { xs: 'none', sm: 'flex' },
borderRadius: 2,
'& .MuiOutlinedInput-notchedOutline': {
borderRadius: 2
}
}}
variant='outlined'
placeholder='Search Name or Email or Phone'
onChange={onSearchChange}
startAdornment={
<Box
sx={{
color: theme.palette.grey[400],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mr: 1
}}
>
<IconSearch style={{ color: 'inherit', width: 16, height: 16 }} />
</Box>
}
type='search'
/>
<div style={{ flex: 1 }} />
{leads && leads.length > 0 && (
<Button variant='outlined' onClick={() => exportMessages()} startIcon={<IconFileExport />}>
Export
</Button>
)}
</div>
</DialogTitle>
<DialogContent>
{leads && leads.length == 0 && (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', width: '100%' }} flexDirection='column'>
<Box sx={{ p: 5, height: 'auto' }}>
<img style={{ objectFit: 'cover', height: '20vh', width: 'auto' }} src={leadsEmptySVG} alt='msgEmptySVG' />
</Box>
<div>No Leads</div>
</Stack>
)}
{leads && leads.length > 0 && (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label='simple table'>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email Address</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Created Date</TableCell>
</TableRow>
</TableHead>
<TableBody>
{leads.filter(filterLeads).map((lead, index) => (
<TableRow key={index}>
<TableCell>{lead.name}</TableCell>
<TableCell>{lead.email}</TableCell>
<TableCell>{lead.phone}</TableCell>
<TableCell>{moment(lead.createdDate).format('MMMM Do, YYYY')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</DialogContent>
</Dialog>
) : null
return createPortal(component, portalElement)
}
ViewLeadsDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onCancel: PropTypes.func
}
export default ViewLeadsDialog
@@ -102,6 +102,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
const [chatTypeFilter, setChatTypeFilter] = useState([])
const [startDate, setStartDate] = useState(new Date().setMonth(new Date().getMonth() - 1))
const [endDate, setEndDate] = useState(new Date())
const [leadEmail, setLeadEmail] = useState('')
const getChatmessageApi = useApi(chatmessageApi.getAllChatmessageFromChatflow)
const getChatmessageFromPKApi = useApi(chatmessageApi.getChatmessageFromPK)
@@ -191,6 +192,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
source: chatmsg.chatType === 'INTERNAL' ? 'UI' : 'API/Embed',
sessionId: chatmsg.sessionId ?? null,
memoryType: chatmsg.memoryType ?? null,
email: leadEmail ?? null,
messages: [msg]
}
} else if (Object.prototype.hasOwnProperty.call(obj, chatPK)) {
@@ -407,6 +409,13 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
setSourceDialogOpen(true)
}
useEffect(() => {
const leadEmailFromChatMessages = chatMessages.filter((message) => message.type === 'userMessage' && message.leadEmail)
if (leadEmailFromChatMessages.length) {
setLeadEmail(leadEmailFromChatMessages[0].leadEmail)
}
}, [chatMessages, selectedMessageIndex])
useEffect(() => {
if (getChatmessageFromPKApi.data) {
getChatMessages(getChatmessageFromPKApi.data)
@@ -450,6 +459,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
setStartDate(new Date().setMonth(new Date().getMonth() - 1))
setEndDate(new Date())
setStats([])
setLeadEmail('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -639,6 +649,11 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
Memory:&nbsp;<b>{chatMessages[1].memoryType}</b>
</div>
)}
{leadEmail && (
<div>
Email:&nbsp;<b>{leadEmail}</b>
</div>
)}
</div>
<div
style={{
@@ -675,6 +690,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
marginLeft: '20px',
border: '1px solid #e0e0e0',
borderRadius: `${customization.borderRadius}px`
@@ -0,0 +1,179 @@
import { useDispatch } from 'react-redux'
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
// material-ui
import { Button, Box, OutlinedInput, Typography } from '@mui/material'
import { IconX } from '@tabler/icons'
// Project import
import { StyledButton } from '@/ui-component/button/StyledButton'
import { SwitchInput } from '@/ui-component/switch/Switch'
// store
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction, SET_CHATFLOW } from '@/store/actions'
import useNotifier from '@/utils/useNotifier'
// API
import chatflowsApi from '@/api/chatflows'
const formTitle = `Hey 👋 thanks for your interest!
Let us know where we can reach you`
const endTitle = `Thank you!
What can I do for you?`
const Leads = ({ dialogProps }) => {
const dispatch = useDispatch()
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
const [leadsConfig, setLeadsConfig] = useState({})
const [chatbotConfig, setChatbotConfig] = useState({})
const handleChange = (key, value) => {
setLeadsConfig({
...leadsConfig,
[key]: value
})
}
const onSave = async () => {
try {
let value = {
leads: leadsConfig
}
chatbotConfig.leads = value.leads
const saveResp = await chatflowsApi.updateChatflow(dialogProps.chatflow.id, {
chatbotConfig: JSON.stringify(chatbotConfig)
})
if (saveResp.data) {
enqueueSnackbar({
message: 'Leads configuration Saved',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
dispatch({ type: SET_CHATFLOW, chatflow: saveResp.data })
}
} catch (error) {
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}`
enqueueSnackbar({
message: `Failed to save Leads configuration: ${errorData}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
useEffect(() => {
if (dialogProps.chatflow && dialogProps.chatflow.chatbotConfig) {
let chatbotConfig = JSON.parse(dialogProps.chatflow.chatbotConfig)
setChatbotConfig(chatbotConfig || {})
if (chatbotConfig.leads) {
setLeadsConfig(chatbotConfig.leads)
}
}
return () => {}
}, [dialogProps])
return (
<>
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
justifyContent: 'start',
gap: 3,
mb: 2
}}
>
<SwitchInput label='Enable Lead Capture' onChange={(value) => handleChange('status', value)} value={leadsConfig.status} />
{leadsConfig && leadsConfig['status'] && (
<>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1 }}>
<Typography>Form Title</Typography>
<OutlinedInput
id='form-title'
type='text'
fullWidth
multiline={true}
minRows={4}
value={leadsConfig.title}
placeholder={formTitle}
name='form-title'
size='small'
onChange={(e) => {
handleChange('title', e.target.value)
}}
/>
</Box>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1 }}>
<Typography>Message after lead captured</Typography>
<OutlinedInput
id='success-message'
type='text'
fullWidth
multiline={true}
minRows={4}
value={leadsConfig.successMessage}
placeholder={endTitle}
name='form-title'
size='small'
onChange={(e) => {
handleChange('successMessage', e.target.value)
}}
/>
</Box>
<Typography variant='h4'>Form fields</Typography>
<Box sx={{ width: '100%' }}>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1 }}>
<SwitchInput label='Name' onChange={(value) => handleChange('name', value)} value={leadsConfig.name} />
<SwitchInput
label='Email Address'
onChange={(value) => handleChange('email', value)}
value={leadsConfig.email}
/>
<SwitchInput label='Phone' onChange={(value) => handleChange('phone', value)} value={leadsConfig.phone} />
</Box>
</Box>
</>
)}
</Box>
<StyledButton
disabled={!leadsConfig['name'] && !leadsConfig['phone'] && !leadsConfig['email'] && leadsConfig['status']}
style={{ marginBottom: 10, marginTop: 10 }}
variant='contained'
onClick={onSave}
>
Save
</StyledButton>
</>
)
}
Leads.propTypes = {
dialogProps: PropTypes.object
}
export default Leads