mirror of
https://github.com/farcasclaudiu/Flowise.git
synced 2026-06-28 09:00:52 +03:00
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:
@@ -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: <b>{chatMessages[1].memoryType}</b>
|
||||
</div>
|
||||
)}
|
||||
{leadEmail && (
|
||||
<div>
|
||||
Email: <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
|
||||
Reference in New Issue
Block a user