├── coverage.lcov ├── .gitattributes ├── .prettierrc ├── tests ├── helpers │ ├── compiler.js │ ├── setup.js │ └── corti.js └── lib │ ├── Input.spec.js │ ├── rgba.spec.js │ ├── SubmitButton.spec.js │ ├── OptionsStep.spec.js │ ├── recognition.spec.js │ ├── CustomStep.spec.js │ ├── speechSynthesis.spec.js │ ├── schema.spec.js │ ├── TextStep.spec.js │ └── ChatBot.spec.js ├── .travis.yml ├── lib ├── index.js ├── components │ ├── Footer.jsx │ ├── HeaderIcon.jsx │ ├── FloatingIcon.jsx │ ├── HeaderTitle.jsx │ ├── Content.jsx │ ├── Header.jsx │ ├── index.js │ ├── FloatButton.jsx │ ├── Input.jsx │ ├── SubmitButton.jsx │ └── ChatBotContainer.jsx ├── utils.js ├── steps_components │ ├── options │ │ ├── OptionsStepContainer.jsx │ │ ├── Options.jsx │ │ ├── Option.jsx │ │ ├── OptionElement.jsx │ │ └── OptionsStep.jsx │ ├── index.jsx │ ├── text │ │ ├── ImageContainer.jsx │ │ ├── TextStepContainer.jsx │ │ ├── Image.jsx │ │ ├── Bubble.jsx │ │ └── TextStep.jsx │ ├── common │ │ ├── LoadingStep.jsx │ │ └── Loading.jsx │ └── custom │ │ ├── CustomStepContainer.jsx │ │ └── CustomStep.jsx ├── icons │ ├── index.jsx │ ├── ChatIcon.jsx │ ├── CloseIcon.jsx │ ├── SubmitIcon.jsx │ └── MicIcon.jsx ├── theme.js ├── schemas │ ├── updateSchema.js │ ├── optionsSchema.js │ ├── userSchema.js │ ├── textSchema.js │ ├── customSchema.js │ └── schema.js ├── common │ ├── animations.jsx │ └── rgba.js ├── speechSynthesis.js ├── storage.js ├── recognition.js └── ChatBot.jsx ├── example ├── main.jsx ├── index.html └── components │ └── Example.jsx ├── babel.config.js ├── .editorconfig ├── .eslintrc ├── .github ├── workflows │ └── nodejs.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── webpack.config.js ├── ISSUE_TEMPLATE.md ├── LICENSE ├── webpack.config.prod.js ├── contributing.md ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── dist └── react-simple-chatbot.js /coverage.lcov: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /tests/helpers/compiler.js: -------------------------------------------------------------------------------- 1 | function noop() { 2 | return null; 3 | } 4 | 5 | require.extensions['.mp3'] = noop; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | before_install: 5 | - "npm install react@16.4.1 react-dom@16.4.1 styled-components@4.1.3" 6 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import ChatBot from './ChatBot'; 2 | import Loading from './steps_components/common/Loading'; 3 | 4 | export default ChatBot; 5 | export { Loading }; 6 | -------------------------------------------------------------------------------- /lib/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Footer = styled.div` 4 | position: relative; 5 | `; 6 | 7 | export default Footer; 8 | -------------------------------------------------------------------------------- /lib/components/HeaderIcon.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const HeaderIcon = styled.a` 4 | cursor: pointer; 5 | `; 6 | 7 | export default HeaderIcon; 8 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | export const isMobile = () => /iphone|ipod|android|ie|blackberry|fennec/i.test(navigator.userAgent); 2 | 3 | export const isString = value => typeof value === 'string'; 4 | -------------------------------------------------------------------------------- /example/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import Example from './components/Example'; 4 | 5 | render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /lib/steps_components/options/OptionsStepContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const OptionsStepContainer = styled.div``; 4 | 5 | export default OptionsStepContainer; 6 | -------------------------------------------------------------------------------- /lib/components/FloatingIcon.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const FloatingIcon = styled.img` 4 | height: 24px; 5 | width: 24px; 6 | `; 7 | 8 | export default FloatingIcon; 9 | -------------------------------------------------------------------------------- /lib/steps_components/options/Options.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Options = styled.ul` 4 | margin: 2px 0 12px 0; 5 | padding: 0 6px; 6 | `; 7 | 8 | export default Options; 9 | -------------------------------------------------------------------------------- /lib/steps_components/index.jsx: -------------------------------------------------------------------------------- 1 | import CustomStep from './custom/CustomStep'; 2 | import OptionsStep from './options/OptionsStep'; 3 | import TextStep from './text/TextStep'; 4 | 5 | export { CustomStep, OptionsStep, TextStep }; 6 | -------------------------------------------------------------------------------- /lib/icons/index.jsx: -------------------------------------------------------------------------------- 1 | import ChatIcon from './ChatIcon'; 2 | import CloseIcon from './CloseIcon'; 3 | import SubmitIcon from './SubmitIcon'; 4 | import MicIcon from './MicIcon'; 5 | 6 | export { ChatIcon, CloseIcon, SubmitIcon, MicIcon }; 7 | -------------------------------------------------------------------------------- /lib/steps_components/text/ImageContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ImageContainer = styled.div` 4 | display: inline-block; 5 | order: ${props => (props.user ? '1' : '0')}; 6 | padding: 6px; 7 | `; 8 | 9 | export default ImageContainer; 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = ['@babel/env', '@babel/preset-react']; 2 | const plugins = [ 3 | '@babel/plugin-transform-arrow-functions', 4 | '@babel/plugin-proposal-class-properties', 5 | '@babel/plugin-transform-object-assign' 6 | ]; 7 | 8 | module.exports = { presets, plugins }; 9 | -------------------------------------------------------------------------------- /lib/steps_components/text/TextStepContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const TextStepContainer = styled.div` 4 | align-items: flex-end; 5 | display: flex; 6 | justify-content: ${props => (props.user ? 'flex-end' : 'flex-start')}; 7 | `; 8 | 9 | export default TextStepContainer; 10 | -------------------------------------------------------------------------------- /lib/theme.js: -------------------------------------------------------------------------------- 1 | export default { 2 | background: '#f5f8fb', 3 | fontFamily: 'monospace', 4 | headerBgColor: '#6e48aa', 5 | headerFontColor: '#fff', 6 | headerFontSize: '16px', 7 | botBubbleColor: '#6E48AA', 8 | botFontColor: '#fff', 9 | userBubbleColor: '#fff', 10 | userFontColor: '#4a4a4a' 11 | }; 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Simple ChatBot 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /lib/steps_components/common/LoadingStep.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { loading } from '../../common/animations'; 3 | 4 | const LoadingStep = styled.span` 5 | animation: ${loading} 1.4s infinite both; 6 | animation-delay: ${props => props.delay}; 7 | `; 8 | 9 | export default LoadingStep; 10 | -------------------------------------------------------------------------------- /lib/components/HeaderTitle.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import defaultTheme from '../theme'; 3 | 4 | const HeaderTitle = styled.h2` 5 | margin: 0; 6 | font-size: ${({ theme }) => theme.headerFontSize}; 7 | `; 8 | 9 | HeaderTitle.defaultProps = { 10 | theme: defaultTheme 11 | }; 12 | 13 | export default HeaderTitle; 14 | -------------------------------------------------------------------------------- /lib/steps_components/options/Option.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { scale } from '../../common/animations'; 3 | 4 | const Option = styled.li` 5 | animation: ${scale} 0.3s ease forwards; 6 | cursor: pointer; 7 | display: inline-block; 8 | margin: 2px; 9 | transform: scale(0); 10 | `; 11 | 12 | export default Option; 13 | -------------------------------------------------------------------------------- /lib/icons/ChatIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChatIcon = () => ( 4 | 5 | 6 | 7 | 8 | ); 9 | 10 | export default ChatIcon; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain 2 | # consistent coding styles between different editors and IDEs. 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /lib/steps_components/common/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoadingStep from './LoadingStep'; 3 | 4 | const Loading = () => ( 5 | 6 | . 7 | . 8 | . 9 | 10 | ); 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /lib/steps_components/custom/CustomStepContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ChatStepContainer = styled.div` 4 | background: #fff; 5 | border-radius: 5px; 6 | box-shadow: rgba(0, 0, 0, 0.15) 0px 1px 2px 0px; 7 | display: flex; 8 | justify-content: center; 9 | margin: 0 6px 10px 6px; 10 | padding: 16px; 11 | `; 12 | 13 | export default ChatStepContainer; 14 | -------------------------------------------------------------------------------- /lib/icons/CloseIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CloseIcon = () => ( 4 | 5 | 9 | 10 | 11 | ); 12 | 13 | export default CloseIcon; 14 | -------------------------------------------------------------------------------- /lib/components/Content.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Content = styled.div` 4 | height: calc(${props => props.height} - ${props => (props.hideInput ? '56px' : '112px')}); 5 | overflow-y: scroll; 6 | margin-top: 2px; 7 | padding-top: 6px; 8 | 9 | @media screen and (max-width: 568px) { 10 | height: ${props => (props.floating ? 'calc(100% - 112px)' : '')}; 11 | } 12 | `; 13 | 14 | export default Content; 15 | -------------------------------------------------------------------------------- /lib/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import defaultTheme from '../theme'; 3 | 4 | const Header = styled.div` 5 | align-items: center; 6 | background: ${({ theme }) => theme.headerBgColor}; 7 | color: ${({ theme }) => theme.headerFontColor}; 8 | display: flex; 9 | fill: ${({ theme }) => theme.headerFontColor}; 10 | height: 56px; 11 | justify-content: space-between; 12 | padding: 0 10px; 13 | `; 14 | 15 | Header.defaultProps = { 16 | theme: defaultTheme 17 | }; 18 | 19 | export default Header; 20 | -------------------------------------------------------------------------------- /lib/steps_components/text/Image.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { scale } from '../../common/animations'; 3 | 4 | const Image = styled.img` 5 | animation: ${scale} 0.3s ease forwards; 6 | border-radius: ${props => (props.user ? '50% 50% 50% 0' : '50% 50% 0 50%')}; 7 | box-shadow: rgba(0, 0, 0, 0.15) 0px 1px 2px 0px; 8 | height: 40px; 9 | min-width: 40px; 10 | padding: 3px; 11 | transform: scale(0); 12 | transform-origin: ${props => (props.user ? 'bottom left' : 'bottom right')}; 13 | width: 40; 14 | `; 15 | 16 | export default Image; 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "plugin:prettier/recommended", "prettier/react"], 3 | "plugins": ["prettier"], 4 | "parser": "babel-eslint", 5 | "globals": { 6 | "window": true, 7 | "Audio": true, 8 | "document": true, 9 | "localStorage": true, 10 | "navigator": true 11 | }, 12 | "rules": { 13 | "prettier/prettier": ["error"], 14 | "no-confusing-arrow": 0, 15 | "no-return-assign": 0, 16 | "guard-for-in": 0, 17 | "no-restricted-syntax": 0, 18 | "no-param-reassign": 0, 19 | "react/no-will-update-set-state": 0, 20 | "new-cap": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [8.x, 10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm ci 23 | npm run build --if-present 24 | npm test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /tests/lib/Input.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import Input from '../../lib/components/Input'; 6 | 7 | describe('Input', () => { 8 | it('should render a disabled input', () => { 9 | const wrapper = mount(); 10 | expect(wrapper.props().disabled).to.be.equal(true); 11 | }); 12 | 13 | it('should render a invalid input', () => { 14 | const wrapper = mount(); 15 | expect(wrapper.props().invalid).to.be.equal(true); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/icons/SubmitIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const SubmitIcon = ({ size }) => ( 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | SubmitIcon.propTypes = { 21 | size: PropTypes.number 22 | }; 23 | 24 | SubmitIcon.defaultProps = { 25 | size: 20 26 | }; 27 | 28 | export default SubmitIcon; 29 | -------------------------------------------------------------------------------- /lib/components/index.js: -------------------------------------------------------------------------------- 1 | import ChatBotContainer from './ChatBotContainer'; 2 | import Content from './Content'; 3 | import Header from './Header'; 4 | import HeaderTitle from './HeaderTitle'; 5 | import HeaderIcon from './HeaderIcon'; 6 | import FloatButton from './FloatButton'; 7 | import FloatingIcon from './FloatingIcon'; 8 | import Footer from './Footer'; 9 | import Input from './Input'; 10 | import SubmitButton from './SubmitButton'; 11 | 12 | export { 13 | ChatBotContainer, 14 | Content, 15 | Header, 16 | HeaderTitle, 17 | HeaderIcon, 18 | FloatButton, 19 | FloatingIcon, 20 | Footer, 21 | Input, 22 | SubmitButton 23 | }; 24 | -------------------------------------------------------------------------------- /tests/lib/rgba.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import rgba from '../../lib/common/rgba'; 4 | 5 | describe('rgba', () => { 6 | it('should transform black to rgba', () => { 7 | const color = rgba('#fff'); 8 | expect(color).to.be.equal('rgba(255, 255, 255, 1)'); 9 | }); 10 | it('should transform red to rgba', () => { 11 | const color = rgba('#ff0000', 0.5); 12 | expect(color).to.be.equal('rgba(255, 0, 0, 0.5)'); 13 | }); 14 | it('should put alpha default', () => { 15 | const color = rgba('#fff', 1); 16 | expect(color).to.be.equal('rgba(255, 255, 255, 1)'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /lib/schemas/updateSchema.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | key: 'id', 4 | types: ['string', 'number'], 5 | required: true 6 | }, 7 | { 8 | key: 'update', 9 | types: ['string', 'number'], 10 | required: true 11 | }, 12 | { 13 | key: 'trigger', 14 | types: ['string', 'number', 'function'], 15 | required: true 16 | }, 17 | { 18 | key: 'placeholder', 19 | types: ['string'], 20 | required: false 21 | }, 22 | { 23 | key: 'inputAttributes', 24 | types: ['object'], 25 | required: false 26 | }, 27 | { 28 | key: 'metadata', 29 | types: ['object'], 30 | required: false 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | /lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | /coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | /build 24 | 25 | # Others 26 | /.nyc_output 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | -------------------------------------------------------------------------------- /lib/steps_components/options/OptionElement.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import defaultTheme from '../../theme'; 3 | 4 | const OptionElement = styled.button` 5 | background: ${({ theme }) => theme.botBubbleColor}; 6 | border: 0; 7 | border-radius: 22px; 8 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); 9 | color: ${({ theme }) => theme.botFontColor}; 10 | display: inline-block; 11 | font-size: 14px; 12 | padding: 12px; 13 | 14 | &:hover { 15 | opacity: 0.7; 16 | } 17 | &:active, 18 | &:hover:focus { 19 | outline:none; 20 | } 21 | `; 22 | 23 | OptionElement.defaultProps = { 24 | theme: defaultTheme 25 | }; 26 | 27 | export default OptionElement; 28 | -------------------------------------------------------------------------------- /lib/common/animations.jsx: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components'; 2 | import rgba from './rgba'; 3 | 4 | const loading = keyframes` 5 | 0% { opacity: .2; } 6 | 20% { opacity: 1; } 7 | 100% { opacity: .2; } 8 | `; 9 | 10 | const scale = keyframes` 11 | 100% { transform: scale(1); } 12 | `; 13 | 14 | const invalidInput = keyframes` 15 | 25% { transform: rotate(-1deg); } 16 | 100% { transform: rotate(1deg); } 17 | `; 18 | 19 | const pulse = color => keyframes` 20 | 0% { box-shadow: 0 0 0 0 ${rgba(color, 0.4)}; } 21 | 70% { box-shadow: 0 0 0 10px ${rgba(color, 0)}; } 22 | 100% { box-shadow: 0 0 0 0 ${rgba(color, 0)}; } 23 | `; 24 | 25 | export { loading, scale, invalidInput, pulse }; 26 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'development', 5 | entry: path.resolve(__dirname, 'example/main.jsx'), 6 | output: { 7 | path: path.resolve(__dirname, 'example'), 8 | publicPath: '/', 9 | filename: 'bundle.js' 10 | }, 11 | devServer: { 12 | contentBase: path.join(__dirname, 'example'), 13 | host: '0.0.0.0', 14 | disableHostCheck: true 15 | }, 16 | resolve: { 17 | extensions: ['.js', '.jsx'] 18 | }, 19 | plugins: [], 20 | devtool: 'source-map', 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.jsx?$/, 25 | exclude: /(node_modules|bower_components)/, 26 | use: { 27 | loader: 'babel-loader' 28 | } 29 | } 30 | ] 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /lib/components/FloatButton.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const FloatButton = styled.a` 4 | align-items: center; 5 | cursor: pointer; 6 | background: ${({ theme }) => theme.headerBgColor}; 7 | bottom: 32px; 8 | border-radius: 100%; 9 | box-shadow: 0 12px 24px 0 rgba(0, 0, 0, 0.15); 10 | display: flex; 11 | fill: ${({ theme }) => theme.headerFontColor}; 12 | height: 56px; 13 | justify-content: center; 14 | position: fixed; 15 | right: 32px; 16 | transform: ${props => (props.opened ? 'scale(0)' : 'scale(1)')}; 17 | transition: transform 0.3s ease; 18 | width: 56px; 19 | z-index: 999; 20 | `; 21 | 22 | FloatButton.defaultProps = { 23 | theme: { 24 | headerBgColor: '#6e48aa', 25 | headerFontColor: '#fff' 26 | } 27 | }; 28 | 29 | export default FloatButton; 30 | -------------------------------------------------------------------------------- /lib/schemas/optionsSchema.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | key: 'id', 4 | types: ['string', 'number'], 5 | required: true 6 | }, 7 | { 8 | key: 'options', 9 | types: ['object'], 10 | required: true 11 | }, 12 | { 13 | key: 'end', 14 | types: ['boolean'], 15 | required: false 16 | }, 17 | { 18 | key: 'placeholder', 19 | types: ['string'], 20 | required: false 21 | }, 22 | { 23 | key: 'hideInput', 24 | types: ['boolean'], 25 | required: false 26 | }, 27 | { 28 | key: 'hideExtraControl', 29 | types: ['boolean'], 30 | required: false 31 | }, 32 | { 33 | key: 'inputAttributes', 34 | types: ['object'], 35 | required: false 36 | }, 37 | { 38 | key: 'metadata', 39 | types: ['object'], 40 | required: false 41 | } 42 | ]; 43 | -------------------------------------------------------------------------------- /example/components/Example.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import ChatBot from '../../lib/index'; 4 | 5 | const otherFontTheme = { 6 | background: '#f5f8fb', 7 | fontFamily: 'Helvetica Neue', 8 | headerBgColor: '#6e48aa', 9 | headerFontColor: '#fff', 10 | headerFontSize: '16px', 11 | botBubbleColor: '#6E48AA', 12 | botFontColor: '#fff', 13 | userBubbleColor: '#fff', 14 | userFontColor: '#4a4a4a' 15 | }; 16 | 17 | const steps = [ 18 | { 19 | id: '1', 20 | message: 'Hello World', 21 | end: true 22 | } 23 | ]; 24 | 25 | const ThemedExample = () => ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | export default ThemedExample; 34 | -------------------------------------------------------------------------------- /lib/common/rgba.js: -------------------------------------------------------------------------------- 1 | const hexToRgb = hex => { 2 | // http://stackoverflow.com/a/5624139 3 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; 4 | hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b); 5 | 6 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 7 | return result 8 | ? { 9 | r: parseInt(result[1], 16), 10 | g: parseInt(result[2], 16), 11 | b: parseInt(result[3], 16) 12 | } 13 | : null; 14 | }; 15 | 16 | /** 17 | * Transform hex+alpha to rgba 18 | * @param {string} hex hex color code 19 | * @param {number} [alpha=1] 20 | * @returns {string} the rgba as string 21 | */ 22 | const rgba = (hex, alpha = 1) => { 23 | const color = hexToRgb(hex); 24 | return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`; 25 | }; 26 | 27 | export default rgba; 28 | -------------------------------------------------------------------------------- /tests/lib/SubmitButton.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import SubmitButton from '../../lib/components/SubmitButton'; 6 | 7 | describe('SubmitButton', () => { 8 | it('should render a disabled button', () => { 9 | const wrapper = mount(); 10 | expect(wrapper.props().disabled).to.be.equal(true); 11 | }); 12 | 13 | it('should render a invalid button', () => { 14 | const wrapper = mount(); 15 | expect(wrapper.props().invalid).to.be.equal(true); 16 | }); 17 | 18 | it('should render a speaking button', () => { 19 | const wrapper = mount(); 20 | expect(wrapper.props().speaking).to.be.equal(true); 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /tests/helpers/setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { configure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | configure({ adapter: new Adapter() }); 6 | 7 | const jsdom = require('jsdom').jsdom; 8 | 9 | const exposedProperties = ['window', 'navigator', 'document']; 10 | const storage = {}; 11 | 12 | global.document = jsdom(''); 13 | global.window = document.defaultView; 14 | global.localStorage = { 15 | getItem(key) { 16 | return storage[key]; 17 | }, 18 | setItem(key, item) { 19 | storage[key] = item; 20 | }, 21 | }; 22 | Object.keys(document.defaultView).forEach((property) => { 23 | if (typeof global[property] === 'undefined') { 24 | exposedProperties.push(property); 25 | global[property] = document.defaultView[property]; 26 | } 27 | }); 28 | 29 | global.navigator = { 30 | userAgent: 'node.js', 31 | }; 32 | -------------------------------------------------------------------------------- /lib/schemas/userSchema.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | key: 'id', 4 | types: ['string', 'number'], 5 | required: true 6 | }, 7 | { 8 | key: 'user', 9 | types: ['boolean'], 10 | required: true 11 | }, 12 | { 13 | key: 'hideExtraControl', 14 | types: ['boolean'], 15 | required: false 16 | }, 17 | { 18 | key: 'trigger', 19 | types: ['string', 'number', 'function'], 20 | required: false 21 | }, 22 | { 23 | key: 'validator', 24 | types: ['function'], 25 | required: false 26 | }, 27 | { 28 | key: 'end', 29 | types: ['boolean'], 30 | required: false 31 | }, 32 | { 33 | key: 'placeholder', 34 | types: ['string'], 35 | required: false 36 | }, 37 | { 38 | key: 'inputAttributes', 39 | types: ['object'], 40 | required: false 41 | }, 42 | { 43 | key: 'metadata', 44 | types: ['object'], 45 | required: false 46 | } 47 | ]; 48 | -------------------------------------------------------------------------------- /lib/speechSynthesis.js: -------------------------------------------------------------------------------- 1 | import { isString } from './utils'; 2 | 3 | export const getSpeakText = step => { 4 | const { message, metadata = {} } = step; 5 | if (isString(metadata.speak)) { 6 | return metadata.speak; 7 | } 8 | if (isString(message)) { 9 | return message; 10 | } 11 | return ''; 12 | }; 13 | 14 | export const speakFn = speechSynthesisOptions => (step, previousValue) => { 15 | const { lang, voice, enable } = speechSynthesisOptions; 16 | const { user } = step; 17 | 18 | if (!window.SpeechSynthesisUtterance || !window.speechSynthesis) { 19 | return; 20 | } 21 | if (user) { 22 | return; 23 | } 24 | if (!enable) { 25 | return; 26 | } 27 | const text = getSpeakText(step); 28 | const msg = new window.SpeechSynthesisUtterance(); 29 | msg.text = text.replace(/{previousValue}/g, previousValue); 30 | msg.lang = lang; 31 | msg.voice = voice; 32 | window.speechSynthesis.speak(msg); 33 | }; 34 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /lib/schemas/textSchema.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | key: 'id', 4 | types: ['string', 'number'], 5 | required: true 6 | }, 7 | { 8 | key: 'message', 9 | types: ['string', 'function'], 10 | required: true 11 | }, 12 | { 13 | key: 'avatar', 14 | types: ['string'], 15 | required: false 16 | }, 17 | { 18 | key: 'trigger', 19 | types: ['string', 'number', 'function'], 20 | required: false 21 | }, 22 | { 23 | key: 'delay', 24 | types: ['number'], 25 | required: false 26 | }, 27 | { 28 | key: 'end', 29 | types: ['boolean'], 30 | required: false 31 | }, 32 | { 33 | key: 'placeholder', 34 | types: ['string'], 35 | required: false 36 | }, 37 | { 38 | key: 'hideInput', 39 | types: ['boolean'], 40 | required: false 41 | }, 42 | { 43 | key: 'hideExtraControl', 44 | types: ['boolean'], 45 | required: false 46 | }, 47 | { 48 | key: 'inputAttributes', 49 | types: ['object'], 50 | required: false 51 | }, 52 | { 53 | key: 'metadata', 54 | types: ['object'], 55 | required: false 56 | } 57 | ]; 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Lucas Bassetti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/components/Input.jsx: -------------------------------------------------------------------------------- 1 | import { invalidInput } from '../common/animations'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | const Input = styled.input` 5 | animation: ${props => 6 | props.invalid 7 | ? css` 8 | ${invalidInput} .2s ease 9 | ` 10 | : ''}; 11 | border: 0; 12 | border-radius: 0; 13 | border-bottom-left-radius: 10px; 14 | border-bottom-right-radius: 10px; 15 | border-top: ${props => (props.invalid ? '0' : '1px solid #eee')}; 16 | box-shadow: ${props => (props.invalid ? 'inset 0 0 2px #E53935' : 'none')}; 17 | box-sizing: border-box; 18 | color: ${props => (props.invalid ? '#E53935' : '')}; 19 | font-size: 16px; 20 | opacity: ${props => (props.disabled && !props.invalid ? '.5' : '1')}; 21 | outline: none; 22 | padding: ${props => (props.hasButton ? '16px 52px 16px 10px' : '16px 10px')}; 23 | width: 100%; 24 | -webkit-appearance: none; 25 | 26 | &:disabled { 27 | background: #fff; 28 | } 29 | 30 | @media screen and (max-width: 568px) { 31 | border-bottom-left-radius: ${props => (props.floating ? '0' : '10px')}; 32 | border-bottom-right-radius: ${props => (props.floating ? '0' : '10px')}; 33 | } 34 | `; 35 | 36 | export default Input; 37 | -------------------------------------------------------------------------------- /lib/icons/MicIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const MicIcon = ({ size }) => ( 5 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | MicIcon.propTypes = { 20 | size: PropTypes.number 21 | }; 22 | 23 | MicIcon.defaultProps = { 24 | size: 20 25 | }; 26 | 27 | export default MicIcon; 28 | -------------------------------------------------------------------------------- /lib/components/SubmitButton.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import defaultTheme from '../theme'; 3 | import { pulse } from '../common/animations'; 4 | 5 | const fillFunc = props => { 6 | const { speaking, invalid, theme } = props; 7 | 8 | if (speaking) { 9 | return theme.headerBgColor; 10 | } 11 | return invalid ? '#E53935' : '#4a4a4a'; 12 | }; 13 | 14 | const SubmitButton = styled.button` 15 | background-color: transparent; 16 | border: 0; 17 | border-bottom-right-radius: 10px; 18 | box-shadow: none; 19 | cursor: ${props => (props.disabled ? 'default' : 'pointer')}; 20 | fill: ${fillFunc}; 21 | opacity: ${props => (props.disabled && !props.invalid ? '.5' : '1')}; 22 | outline: none; 23 | padding: 14px 16px 12px 16px; 24 | &:before { 25 | content: ''; 26 | position: absolute; 27 | width: 23px; 28 | height: 23px; 29 | border-radius: 50%; 30 | animation: ${({ theme, speaking }) => 31 | speaking 32 | ? css` 33 | ${pulse(theme.headerBgColor)} 2s ease infinite 34 | ` 35 | : ''}; 36 | } 37 | &:not(:disabled):hover { 38 | opacity: 0.7; 39 | } 40 | `; 41 | 42 | SubmitButton.defaultProps = { 43 | theme: defaultTheme 44 | }; 45 | 46 | export default SubmitButton; 47 | -------------------------------------------------------------------------------- /tests/lib/OptionsStep.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import { OptionsStep } from '../../lib/steps_components'; 6 | import OptionElement from '../../lib/steps_components/options/OptionElement'; 7 | 8 | describe('OptionsStep', () => { 9 | const settings = { 10 | step: { 11 | id: '1', 12 | options: [ 13 | { value: 'op1', label: 'Option 1', target: '2' }, 14 | { value: 'op2', label: 'Option 2', target: '3' }, 15 | ], 16 | // bubbleColor: '#eee', 17 | // fontColor: '#000', 18 | }, 19 | bubbleStyle: {}, 20 | triggerNextStep: () => {}, 21 | }; 22 | 23 | const wrapper = mount(); 24 | wrapper.setState({ loading: false }); 25 | 26 | it('should render', () => { 27 | expect(wrapper.find(OptionsStep).length).to.be.equal(1); 28 | }); 29 | 30 | it('should render 2 options', () => { 31 | expect(wrapper.find(OptionElement).length).to.be.equal(2); 32 | }); 33 | 34 | it('should render the first option with label equal \'Option 1\'', () => { 35 | const label = wrapper.find(OptionElement).first().text(); 36 | expect(label).to.be.equal('Option 1'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /lib/schemas/customSchema.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | key: 'id', 4 | types: ['string', 'number'], 5 | required: true 6 | }, 7 | { 8 | key: 'component', 9 | types: ['any'], 10 | required: true 11 | }, 12 | { 13 | key: 'avatar', 14 | types: ['string'], 15 | required: false 16 | }, 17 | { 18 | key: 'replace', 19 | types: ['boolean'], 20 | required: false 21 | }, 22 | { 23 | key: 'waitAction', 24 | types: ['boolean'], 25 | required: false 26 | }, 27 | { 28 | key: 'asMessage', 29 | types: ['boolean'], 30 | required: false 31 | }, 32 | { 33 | key: 'trigger', 34 | types: ['string', 'number', 'function'], 35 | required: false 36 | }, 37 | { 38 | key: 'delay', 39 | types: ['number'], 40 | required: false 41 | }, 42 | { 43 | key: 'end', 44 | types: ['boolean'], 45 | required: false 46 | }, 47 | { 48 | key: 'placeholder', 49 | types: ['string'], 50 | required: false 51 | }, 52 | { 53 | key: 'hideInput', 54 | types: ['boolean'], 55 | required: false 56 | }, 57 | { 58 | key: 'hideExtraControl', 59 | types: ['boolean'], 60 | required: false 61 | }, 62 | { 63 | key: 'inputAttributes', 64 | types: ['object'], 65 | required: false 66 | }, 67 | { 68 | key: 'metadata', 69 | types: ['object'], 70 | required: false 71 | } 72 | ]; 73 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 5 | 6 | module.exports = { 7 | mode: 'production', 8 | devtool: 'source-map', 9 | entry: path.resolve(__dirname, 'lib/index'), 10 | externals: { 11 | 'styled-components': 'styled-components', 12 | react: 'react' 13 | }, 14 | optimization: { 15 | minimizer: [ 16 | new TerserPlugin({ 17 | parallel: true, 18 | sourceMap: true, 19 | terserOptions: { 20 | output: { 21 | comments: false, 22 | } 23 | }, 24 | }), 25 | ] 26 | }, 27 | output: { 28 | path: path.resolve(__dirname, 'dist'), 29 | filename: 'react-simple-chatbot.js', 30 | publicPath: 'dist/', 31 | library: 'ReactSimpleChatbot', 32 | libraryTarget: 'umd', 33 | globalObject: "typeof self !== 'undefined' ? self : this" 34 | }, 35 | resolve: { 36 | extensions: ['.js', '.jsx'] 37 | }, 38 | plugins: [ 39 | new CleanWebpackPlugin(['dist']), 40 | process.env.BUNDLE_ANALYZE === 'true' ? new BundleAnalyzerPlugin() : () => { } 41 | ], 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.jsx?$/, 46 | exclude: /(node_modules|bower_components)/, 47 | use: { 48 | loader: 'babel-loader' 49 | } 50 | } 51 | ] 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/components/ChatBotContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import defaultTheme from '../theme'; 3 | 4 | const ChatBotContainer = styled.div` 5 | background: ${({ theme }) => theme.background}; 6 | border-radius: 10px; 7 | box-shadow: 0 12px 24px 0 rgba(0, 0, 0, 0.15); 8 | font-family: ${({ theme }) => theme.fontFamily}; 9 | overflow: hidden; 10 | position: ${({ floating }) => (floating ? 'fixed' : 'relative')}; 11 | bottom: ${({ floating, floatingStyle }) => 12 | floating ? floatingStyle.bottom || '32px' : 'initial'}; 13 | top: ${({ floating, floatingStyle }) => (floating ? floatingStyle.top || 'initial' : 'initial')}; 14 | right: ${({ floating, floatingStyle }) => (floating ? floatingStyle.right || '32px' : 'initial')}; 15 | left: ${({ floating, floatingStyle }) => 16 | floating ? floatingStyle.left || 'initial' : 'initial'}; 17 | width: ${({ width }) => width}; 18 | height: ${({ height }) => height}; 19 | z-index: 999; 20 | transform: ${({ opened }) => (opened ? 'scale(1)' : 'scale(0)')}; 21 | transform-origin: ${({ floatingStyle }) => floatingStyle.transformOrigin || 'bottom right'}; 22 | transition: transform 0.3s ease; 23 | 24 | @media screen and (max-width: 568px) { 25 | border-radius: ${({ floating }) => (floating ? '0' : '')}; 26 | bottom: 0 !important; 27 | left: initial !important; 28 | height: 100%; 29 | right: 0 !important; 30 | top: initial !important; 31 | width: 100%; 32 | } 33 | `; 34 | 35 | ChatBotContainer.defaultProps = { 36 | theme: defaultTheme 37 | }; 38 | 39 | export default ChatBotContainer; 40 | -------------------------------------------------------------------------------- /lib/steps_components/options/OptionsStep.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Option from './Option'; 4 | import OptionElement from './OptionElement'; 5 | import Options from './Options'; 6 | import OptionsStepContainer from './OptionsStepContainer'; 7 | 8 | class OptionsStep extends Component { 9 | onOptionClick = ({ value }) => { 10 | const { triggerNextStep } = this.props; 11 | 12 | triggerNextStep({ value }); 13 | }; 14 | 15 | renderOption = option => { 16 | const { bubbleOptionStyle, step } = this.props; 17 | const { user } = step; 18 | const { value, label } = option; 19 | 20 | return ( 21 | 31 | ); 32 | }; 33 | 34 | render() { 35 | const { step } = this.props; 36 | const { options } = step; 37 | 38 | return ( 39 | 40 | 41 | {Object.keys(options).map(key => options[key]).map(this.renderOption)} 42 | 43 | 44 | ); 45 | } 46 | } 47 | 48 | OptionsStep.propTypes = { 49 | bubbleOptionStyle: PropTypes.objectOf(PropTypes.any).isRequired, 50 | step: PropTypes.objectOf(PropTypes.any).isRequired, 51 | triggerNextStep: PropTypes.func.isRequired 52 | }; 53 | 54 | export default OptionsStep; 55 | -------------------------------------------------------------------------------- /lib/steps_components/text/Bubble.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { scale } from '../../common/animations'; 3 | import defaultTheme from '../../theme'; 4 | 5 | const Bubble = styled.div` 6 | animation: ${scale} 0.3s ease forwards; 7 | background: ${props => (props.user ? props.theme.userBubbleColor : props.theme.botBubbleColor)}; 8 | border-radius: ${props => { 9 | const { isFirst, isLast, user } = props; 10 | 11 | if (!isFirst && !isLast) { 12 | return user ? '18px 0 0 18px' : '0 18px 18px 0px'; 13 | } 14 | 15 | if (!isFirst && isLast) { 16 | return user ? '18px 0 18px 18px' : '0 18px 18px 18px'; 17 | } 18 | 19 | return props.user ? '18px 18px 0 18px' : '18px 18px 18px 0'; 20 | }}; 21 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); 22 | color: ${props => (props.user ? props.theme.userFontColor : props.theme.botFontColor)}; 23 | display: inline-block; 24 | font-size: 14px; 25 | max-width: 50%; 26 | margin: ${props => { 27 | const { isFirst, showAvatar, user } = props; 28 | 29 | if (!isFirst && showAvatar) { 30 | return user ? '-8px 46px 10px 0' : '-8px 0 10px 46px'; 31 | } 32 | 33 | if (!isFirst && !showAvatar) { 34 | return user ? '-8px 0px 10px 0' : '-8px 0 10px 0px'; 35 | } 36 | 37 | return '0 0 10px 0'; 38 | }}; 39 | overflow: hidden; 40 | position: relative; 41 | padding: 12px; 42 | transform: scale(0); 43 | transform-origin: ${props => { 44 | const { isFirst, user } = props; 45 | 46 | if (isFirst) { 47 | return user ? 'bottom right' : 'bottom left'; 48 | } 49 | 50 | return user ? 'top right' : 'top left'; 51 | }}; 52 | `; 53 | 54 | Bubble.defaultProps = { 55 | theme: defaultTheme 56 | }; 57 | 58 | export default Bubble; 59 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing guide 2 | 3 | Want to contribute to React Simple Chatbot? Awesome! 4 | There are many ways you can contribute, see below. 5 | 6 | ## Opening issues 7 | 8 | Open an issue to report bugs or to propose new features. 9 | 10 | - Reporting bugs: describe the bug as clearly as you can, including steps to reproduce, what happened and what you were expecting to happen. Also include browser version, OS and other related software's (npm, Node.js, etc) versions when applicable. 11 | 12 | - Proposing features: explain the proposed feature, what it should do, why it is useful, how users should use it. Give us as much info as possible so it will be easier to discuss, access and implement the proposed feature. When you're unsure about a certain aspect of the feature, feel free to leave it open for others to discuss and find an appropriate solution. 13 | 14 | ## Proposing pull requests 15 | 16 | Pull requests are very welcome. Note that if you are going to propose drastic changes, be sure to open an issue for discussion first, to make sure that your PR will be accepted before you spend effort coding it. 17 | 18 | Fork the repository, clone it locally and create a branch for your proposed bug fix or new feature. Avoid working directly on the master branch. 19 | 20 | Implement your bug fix or feature, write tests to cover it and make sure all tests are passing (run a final `npm test` to make sure everything is correct). Then commit your changes, push your bug fix/feature branch to the origin (your forked repo) and open a pull request to the upstream (the repository you originally forked)'s master branch. 21 | 22 | ## Documentation 23 | 24 | Documentation is extremely important and takes a fair deal of time and effort to write and keep updated. Please submit any and all improvements you can make to the repository's docs. 25 | -------------------------------------------------------------------------------- /tests/lib/recognition.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { spy } from 'sinon'; 4 | import Recognition from '../../lib/recognition'; 5 | import newSpeechRecognition from '../helpers/corti'; 6 | 7 | describe('Recognition', () => { 8 | describe('Recognition is not supported', () => { 9 | it('should not be supported', () => { 10 | expect(Recognition.isSupported()).to.be.equal(false); 11 | }); 12 | }); 13 | describe('Recognition supported', () => { 14 | beforeEach(() => { 15 | window.webkitSpeechRecognition = newSpeechRecognition; 16 | }); 17 | 18 | it('should be supported', () => { 19 | expect(Recognition.isSupported()).to.be.equal(true); 20 | }); 21 | 22 | it('should call onChange', () => { 23 | const onChange = spy(); 24 | const recognition = new Recognition(onChange); 25 | recognition.speak(); 26 | recognition.recognition.say('hi, this is a test'); 27 | expect(onChange.called).to.be.equal(true); 28 | expect(onChange.args[0][0]).to.be.equal('hi, this is a test'); 29 | }); 30 | 31 | it('should not call end after 0s', () => { 32 | const onChange = spy(); 33 | const onEnd = spy(); 34 | const recognition = new Recognition(onChange, onEnd); 35 | recognition.speak(); 36 | recognition.recognition.say('hi, this is a test'); 37 | expect(onEnd.called).to.be.equal(false); 38 | }); 39 | 40 | it('should call end after 1s', () => { 41 | const onChange = spy(); 42 | const onEnd = spy(); 43 | const recognition = new Recognition(onChange, onEnd); 44 | recognition.speak(); 45 | recognition.recognition.say('hi, this is a test'); 46 | setTimeout(() => { 47 | expect(onEnd.called).to.be.equal(true); 48 | }, 1000); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /lib/steps_components/custom/CustomStep.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Loading from '../common/Loading'; 4 | import CustomStepContainer from './CustomStepContainer'; 5 | 6 | class CustomStep extends Component { 7 | state = { 8 | loading: true 9 | }; 10 | 11 | componentDidMount() { 12 | const { speak, step, previousValue, triggerNextStep } = this.props; 13 | const { delay, waitAction } = step; 14 | 15 | setTimeout(() => { 16 | this.setState({ loading: false }, () => { 17 | if (!waitAction && !step.rendered) { 18 | triggerNextStep(); 19 | } 20 | speak(step, previousValue); 21 | }); 22 | }, delay); 23 | } 24 | 25 | renderComponent = () => { 26 | const { step, steps, previousStep, triggerNextStep } = this.props; 27 | const { component } = step; 28 | 29 | return React.cloneElement(component, { 30 | step, 31 | steps, 32 | previousStep, 33 | triggerNextStep 34 | }); 35 | }; 36 | 37 | render() { 38 | const { loading } = this.state; 39 | const { style } = this.props; 40 | 41 | return ( 42 | 43 | {loading ? : this.renderComponent()} 44 | 45 | ); 46 | } 47 | } 48 | 49 | CustomStep.propTypes = { 50 | previousStep: PropTypes.objectOf(PropTypes.any).isRequired, 51 | previousValue: PropTypes.oneOfType([ 52 | PropTypes.string, 53 | PropTypes.bool, 54 | PropTypes.number, 55 | PropTypes.object, 56 | PropTypes.array 57 | ]), 58 | speak: PropTypes.func, 59 | step: PropTypes.objectOf(PropTypes.any).isRequired, 60 | steps: PropTypes.objectOf(PropTypes.any).isRequired, 61 | style: PropTypes.objectOf(PropTypes.any).isRequired, 62 | triggerNextStep: PropTypes.func.isRequired 63 | }; 64 | CustomStep.defaultProps = { 65 | previousValue: '', 66 | speak: () => {} 67 | }; 68 | 69 | export default CustomStep; 70 | -------------------------------------------------------------------------------- /tests/lib/CustomStep.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import { CustomStep } from '../../lib/steps_components'; 6 | import CustomStepContainer from '../../lib/steps_components/custom/CustomStepContainer'; 7 | 8 | const Example = () => ( 9 |
10 | Example 11 |
12 | ); 13 | 14 | describe('CustomStep', () => { 15 | describe('Without wait user', () => { 16 | const steps = { 17 | step1: { 18 | id: '1', 19 | component: , 20 | }, 21 | }; 22 | const step = steps.step1; 23 | const settings = { 24 | step, 25 | steps, 26 | delay: 0, 27 | style: { border: 0 }, 28 | previousStep: step, 29 | triggerNextStep: () => {}, 30 | }; 31 | 32 | const wrapper = mount(); 33 | wrapper.setState({ loading: false }); 34 | 35 | it('should render', () => { 36 | expect(wrapper.find(CustomStepContainer)).to.have.length(1); 37 | }); 38 | 39 | it('should render without boder', () => { 40 | expect(wrapper.find(CustomStepContainer).props().style.border).to.be.equal(0); 41 | }); 42 | 43 | it('should render with Example component', () => { 44 | expect(wrapper.find(Example)).to.have.length(1); 45 | }); 46 | }); 47 | 48 | describe('With wait user', () => { 49 | const steps = { 50 | step1: { 51 | id: '1', 52 | component: , 53 | waitAction: true, 54 | }, 55 | }; 56 | const step = steps.step1; 57 | const settings = { 58 | step, 59 | steps, 60 | delay: 0, 61 | previousStep: step, 62 | style: {}, 63 | triggerNextStep: () => {}, 64 | }; 65 | 66 | const wrapper = mount(); 67 | wrapper.setState({ loading: false }); 68 | 69 | it('should render', () => { 70 | expect(wrapper.find(CustomStepContainer)).to.have.length(1); 71 | }); 72 | 73 | it('should render with Example component', () => { 74 | expect(wrapper.find(Example)).to.have.length(1); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /lib/schemas/schema.js: -------------------------------------------------------------------------------- 1 | import userSchema from './userSchema'; 2 | import textSchema from './textSchema'; 3 | import optionsSchema from './optionsSchema'; 4 | import customSchema from './customSchema'; 5 | import updateSchema from './updateSchema'; 6 | import { stringify } from 'flatted/cjs'; 7 | 8 | const schema = { 9 | parse(step) { 10 | let parser = []; 11 | 12 | if (step.user) { 13 | parser = userSchema; 14 | } else if (step.message) { 15 | parser = textSchema; 16 | } else if (step.options) { 17 | parser = optionsSchema; 18 | } else if (step.component) { 19 | parser = customSchema; 20 | } else if (step.update) { 21 | parser = updateSchema; 22 | } else { 23 | throw new Error(`The step ${stringify(step)} is invalid`); 24 | } 25 | 26 | for (let i = 0, len = parser.length; i < len; i += 1) { 27 | const { key, types, required } = parser[i]; 28 | 29 | if (!step[key] && required) { 30 | throw new Error(`Key '${key}' is required in step ${stringify(step)}`); 31 | } else if (step[key]) { 32 | if (types[0] !== 'any' && types.indexOf(typeof step[key]) < 0) { 33 | throw new Error( 34 | `The type of '${key}' value must be ${types.join(' or ')} instead of ${typeof step[ 35 | key 36 | ]}` 37 | ); 38 | } 39 | } 40 | } 41 | 42 | const keys = parser.map(p => p.key); 43 | 44 | for (const key in step) { 45 | if (keys.indexOf(key) < 0) { 46 | console.error(`Invalid key '${key}' in step '${step.id}'`); 47 | delete step[key]; 48 | } 49 | } 50 | 51 | return step; 52 | }, 53 | 54 | checkInvalidIds(steps) { 55 | for (const key in steps) { 56 | const step = steps[key]; 57 | const triggerId = steps[key].trigger; 58 | 59 | if (typeof triggerId !== 'function') { 60 | if (step.options) { 61 | const triggers = step.options.filter(option => typeof option.trigger !== 'function'); 62 | const optionsTriggerIds = triggers.map(option => option.trigger); 63 | 64 | for (let i = 0, len = optionsTriggerIds.length; i < len; i += 1) { 65 | const optionTriggerId = optionsTriggerIds[i]; 66 | if (optionTriggerId && !steps[optionTriggerId]) { 67 | throw new Error( 68 | `The id '${optionTriggerId}' triggered by option ${i + 1} in step '${ 69 | steps[key].id 70 | }' does not exist` 71 | ); 72 | } 73 | } 74 | } else if (triggerId && !steps[triggerId]) { 75 | throw new Error( 76 | `The id '${triggerId}' triggered by step '${steps[key].id}' does not exist` 77 | ); 78 | } 79 | } 80 | } 81 | } 82 | }; 83 | 84 | export default schema; 85 | -------------------------------------------------------------------------------- /tests/lib/speechSynthesis.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | it, 4 | afterEach, 5 | beforeEach, 6 | } from 'mocha'; 7 | import { expect } from 'chai'; 8 | import { spy } from 'sinon'; 9 | 10 | import { getSpeakText, speakFn } from '../../lib/speechSynthesis'; 11 | 12 | describe('SpeechSynthesis', () => { 13 | describe('getSpeakTest', () => { 14 | it('should get speak from metadata', () => { 15 | const text = getSpeakText({ metadata: { speak: 'test' } }); 16 | expect(text).to.eql('test'); 17 | }); 18 | 19 | it('should get speak from metadata before message', () => { 20 | const text = getSpeakText({ message: 'message', metadata: { speak: 'test' } }); 21 | expect(text).to.eql('test'); 22 | }); 23 | 24 | it('should get speak from message if metadata.speak is empty', () => { 25 | const text = getSpeakText({ message: 'message', metadata: { speak: null } }); 26 | expect(text).to.eql('message'); 27 | }); 28 | 29 | it('should get speak from message', () => { 30 | const text = getSpeakText({ message: 'message' }); 31 | expect(text).to.eql('message'); 32 | }); 33 | 34 | it('should fallback to empty string', () => { 35 | const text = getSpeakText({}); 36 | expect(text).to.eql(''); 37 | }); 38 | }); 39 | 40 | describe('speak', () => { 41 | const speakSpy = spy(); 42 | 43 | beforeEach(() => { 44 | global.window.speechSynthesis = { 45 | speak: speakSpy, 46 | }; 47 | global.window.SpeechSynthesisUtterance = function SpeechSynthesisUtterance() {}; 48 | }); 49 | 50 | afterEach(() => { 51 | speakSpy.resetHistory(); 52 | }); 53 | 54 | it('should not speak if disabled', () => { 55 | const speak = speakFn({ enable: false }); 56 | speak({}); 57 | expect(speakSpy.called).to.eql(false); 58 | }); 59 | 60 | it('should not speak if SpeechSynthesisUtterance is not supported', () => { 61 | global.window.SpeechSynthesisUtterance = undefined; 62 | const speak = speakFn({ enable: true }); 63 | speak({}); 64 | expect(speakSpy.called).to.eql(false); 65 | }); 66 | 67 | it('should not speak if speechSynthesis is not supported', () => { 68 | global.window.speechSynthesis = undefined; 69 | const speak = speakFn({ enable: true }); 70 | speak({}); 71 | expect(speakSpy.called).to.eql(false); 72 | }); 73 | 74 | it("should not speak if it's user msg", () => { 75 | const speak = speakFn({ enable: true }); 76 | speak({ user: true }); 77 | expect(speakSpy.called).to.eql(false); 78 | }); 79 | 80 | it('should speak empty string (nothing)', () => { 81 | const speak = speakFn({ enable: true }); 82 | speak({}); 83 | expect(speakSpy.getCall(0).args[0]).to.eql({ text: '', lang: undefined, voice: undefined }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /lib/storage.js: -------------------------------------------------------------------------------- 1 | import { stringify, parse } from 'flatted/cjs' 2 | 3 | /* istanbul ignore next */ 4 | const getData = (params, callback) => { 5 | const { cacheName, cache, firstStep, steps } = params; 6 | const currentStep = firstStep; 7 | const renderedSteps = [steps[currentStep.id]]; 8 | const previousSteps = [steps[currentStep.id]]; 9 | const previousStep = {}; 10 | const unParsedCache = localStorage.getItem(cacheName); 11 | 12 | if (cache && unParsedCache) { 13 | try { 14 | const data = parse(unParsedCache); 15 | const lastStep = data.renderedSteps[data.renderedSteps.length - 1]; 16 | 17 | if (lastStep && lastStep.end) { 18 | localStorage.removeItem(cacheName); 19 | } else { 20 | for (let i = 0, len = data.renderedSteps.length; i < len; i += 1) { 21 | const renderedStep = data.renderedSteps[i]; 22 | // remove delay of cached rendered steps 23 | data.renderedSteps[i].delay = 0; 24 | // flag used to avoid call triggerNextStep in cached rendered steps 25 | data.renderedSteps[i].rendered = true; 26 | 27 | // an error is thrown when render a component from localStorage. 28 | // So it's necessary reassing the component 29 | if (renderedStep.component) { 30 | const { id } = renderedStep; 31 | data.renderedSteps[i].component = steps[id].component; 32 | } 33 | } 34 | 35 | const { trigger, end, options } = data.currentStep; 36 | const { id } = data.currentStep; 37 | 38 | if (options) { 39 | delete data.currentStep.rendered; 40 | } 41 | 42 | // add trigger function to current step 43 | if (!trigger && !end) { 44 | if (options) { 45 | for (let i = 0; i < options.length; i += 1) { 46 | data.currentStep.options[i].trigger = steps[id].options[i].trigger; 47 | } 48 | } else { 49 | data.currentStep.trigger = steps[id].trigger; 50 | } 51 | } 52 | 53 | // execute callback function to enable input if last step is 54 | // waiting user type 55 | if (data.currentStep.user) { 56 | callback(); 57 | } 58 | 59 | return data; 60 | } 61 | } catch (error) { 62 | console.info(`Unable to parse cache named:${cacheName}. \nThe cache where probably created with an older version of react-simple-chatbot.\n`, error); 63 | } 64 | } 65 | 66 | return { 67 | currentStep, 68 | previousStep, 69 | previousSteps, 70 | renderedSteps 71 | }; 72 | }; 73 | 74 | /* istanbul ignore next */ 75 | const setData = (cacheName, cachedData) => { 76 | const data = parse(stringify(cachedData)); 77 | // clean components 78 | for (const key in data) { 79 | for (let i = 0, len = data[key].length; i < len; i += 1) { 80 | if (data[key][i].component) { 81 | data[key][i].component = data[key][i].id; 82 | } 83 | } 84 | } 85 | 86 | localStorage.setItem(cacheName, stringify(data)); 87 | }; 88 | 89 | export { getData, setData }; 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simple-chatbot", 3 | "version": "0.6.1", 4 | "description": "React Simple Chatbot", 5 | "main": "dist/react-simple-chatbot.js", 6 | "scripts": { 7 | "lint": "./node_modules/.bin/eslint lib/*.jsx", 8 | "prepublish": "npm run build", 9 | "prepush": "npm run lint && npm run test:coverage", 10 | "start": "./node_modules/.bin/webpack-dev-server --inline --content-base build/", 11 | "prettier-watch": "onchange '**/*.js' '**/*.jsx' -- prettier --write {{changed}}", 12 | "report-coverage": "nyc report --reporter=lcov > coverage.lcov && codecov", 13 | "test": "./node_modules/.bin/mocha tests/helpers/setup.js tests/**/*.spec.js --require @babel/register", 14 | "test:watch": "npm test -- --watch", 15 | "test:coverage": "nyc npm test", 16 | "build": "./node_modules/.bin/webpack --config webpack.config.prod.js -p", 17 | "analyze": "BUNDLE_ANALYZE=true ./node_modules/.bin/webpack --config webpack.config.prod.js -p" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "nyc": { 23 | "function": 80, 24 | "lines": 80, 25 | "check-coverage": true, 26 | "reporter": [ 27 | "text", 28 | "html" 29 | ], 30 | "exclude": [ 31 | "tests/**" 32 | ], 33 | "extension": [ 34 | ".jsx" 35 | ] 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/LucasBassetti/react-simple-chatbot" 40 | }, 41 | "keywords": [ 42 | "react", 43 | "chat", 44 | "chatbot", 45 | "conversational-ui" 46 | ], 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/LucasBassetti/react-simple-chatbot/issues" 50 | }, 51 | "homepage": "https://github.com/LucasBassetti/react-simple-chatbot#readme", 52 | "devDependencies": { 53 | "@babel/cli": "^7.1.2", 54 | "@babel/core": "^7.1.2", 55 | "@babel/plugin-proposal-class-properties": "^7.1.0", 56 | "@babel/plugin-transform-arrow-functions": "^7.0.0", 57 | "@babel/plugin-transform-object-assign": "^7.2.0", 58 | "@babel/preset-env": "^7.1.0", 59 | "@babel/preset-react": "^7.0.0", 60 | "@babel/register": "^7.0.0", 61 | "babel-eslint": "^10.0.1", 62 | "babel-loader": "^8.0.4", 63 | "chai": "^4.0.2", 64 | "clean-webpack-plugin": "^0.1.16", 65 | "codecov": "^3.1.0", 66 | "enzyme": "^3.7.0", 67 | "enzyme-adapter-react-16": "^1.6.0", 68 | "eslint": "^5.8.0", 69 | "eslint-config-airbnb": "^17.1.0", 70 | "eslint-plugin-import": "^2.14.0", 71 | "eslint-plugin-jsx-a11y": "^6.1.2", 72 | "eslint-plugin-react": "^7.11.1", 73 | "husky": "^0.13.3", 74 | "jsdom": "^9.12.0", 75 | "mocha": "^5.2.0", 76 | "nyc": "^11.0.2", 77 | "react-test-renderer": "^16.0.0", 78 | "sinon": "^7.1.0", 79 | "styled-components": "^4.1.3", 80 | "webpack": "^4.29.6", 81 | "webpack-bundle-analyzer": "^3.3.2", 82 | "webpack-cli": "^3.1.2", 83 | "webpack-dev-server": "^3.1.10" 84 | }, 85 | "dependencies": { 86 | "eslint-config-prettier": "^4.1.0", 87 | "eslint-plugin-prettier": "^3.0.1", 88 | "onchange": "^5.2.0", 89 | "prettier": "^1.16.4", 90 | "flatted": "^2.0.0", 91 | "prop-types": "^15.6.0", 92 | "random-id": "0.0.2", 93 | "react": "^16.4.1", 94 | "react-dom": "^16.4.1" 95 | }, 96 | "peerDependencies": { 97 | "styled-components": "^4.0.0", 98 | "react": "^16.3.0", 99 | "react-dom": "^16.3.0" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at lucasbr.dafonseca@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > React Simple Chatbot is no longer maintained. I recommend using [react-chatbotify](https://github.com/tjtanjin/react-chatbotify) as an alternative. 3 | 4 | 5 | 6 | # React Simple Chatbot 7 | 8 | Travis CI npm version 9 | Codecov 10 | 11 | 12 | A simple chatbot component to create conversation chats 13 | 14 | 15 | 16 | ## Getting Start 17 | 18 | ```bash 19 | npm install react-simple-chatbot --save 20 | ``` 21 | 22 | ## Usage 23 | 24 | There are several examples on the [website](http://lucasbassetti.com.br/react-simple-chatbot). Here is the first one to get you started: 25 | 26 | ``` javascript 27 | import ChatBot from 'react-simple-chatbot'; 28 | 29 | const steps = [ 30 | { 31 | id: '0', 32 | message: 'Welcome to react chatbot!', 33 | trigger: '1', 34 | }, 35 | { 36 | id: '1', 37 | message: 'Bye!', 38 | end: true, 39 | }, 40 | ]; 41 | 42 | ReactDOM.render( 43 |
44 | 45 |
, 46 | document.getElementById('root') 47 | ); 48 | ``` 49 | 50 | ## React Simple Chatbot with AI 51 | 52 | 1. [CodeParrot AI](https://codeparrot.ai/oracle?owner=LucasBassetti&repo=react-simple-chatbot) - Bot will help you understand this repository better. You can ask for code examples, installation guide, debugging help and much more. 53 | 54 | ## React Simple Chatbot on media 55 | 56 | 1. [webdesignerdepot](https://www.webdesignerdepot.com/2017/08/whats-new-for-designers-august-2017/) 57 | 2. [blogduwebdesign](http://www.blogduwebdesign.com/webdesign/ressources-web-du-lundi-aout-164/2507) 58 | 3. [codrops](https://tympanus.net/codrops/collective/collective-335/) 59 | 60 | ## Build with `react-simple-chatbot` 61 | 62 | 1. [Seth Loh Website](https://github.com/lackdaz/lackdaz.github.io) - Personal website of Seth Loh ([demo](https://www.sethloh.com)) 63 | 2. [Paul's Website](https://psheon.github.io/) - Personal website of Paul Jiang ([demo](https://psheon.github.io/archives/)) 64 | 3. [Cisco Partner Support API Chatbot](https://github.com/btotharye/cisco-pss-api-chatbot) - Code with screenshots to have your own Cisco Serial lookup chatbot. 65 | 4. [Chatcompose](https://www.chatcompose.com/en.html) - Chatbot Platform for Conversational Marketing and Support. 66 | 5. [Mixat](https://www.svt.se/mixat) - News Chatbot for tweenies. Also as app ([iOS](https://apps.apple.com/se/app/mixat-h%C3%A4r-f%C3%A5r-du-koll/id1239444432) or [Android](https://play.google.com/store/apps/details?id=se.svt.mixat)) 67 | 68 | Built something with `react-simple-chatbot`? Submit a PR and add it to this list! 69 | 70 | ## How to Contribute 71 | 72 | Please check the [contributing guide](https://github.com/LucasBassetti/react-simple-chatbot/blob/master/contributing.md) 73 | 74 | ## Authors 75 | 76 | | ![Lucas Bassetti](https://avatars3.githubusercontent.com/u/1014326?v=3&s=150)| 77 | |:---------------------:| 78 | | [Lucas Bassetti](https://github.com/LucasBassetti/) | 79 | 80 | See also the list of [contributors](https://github.com/LucasBassetti/react-simple-chatbot/contributors) who participated in this project. 81 | 82 | ## License 83 | 84 | MIT · [Lucas Bassetti](http://lucasbassetti.com.br) 85 | -------------------------------------------------------------------------------- /tests/lib/schema.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import schema from '../../lib/schemas/schema'; 5 | import { stringify } from 'flatted'; 6 | 7 | describe('schema', () => { 8 | it('should throw a invalid step error', () => { 9 | const step = { test: 'test' }; 10 | expect(() => { 11 | schema.parse(step); 12 | }).to.throw(Error, `The step ${stringify(step)} is invalid`); 13 | }); 14 | 15 | it('should throw a key required error', () => { 16 | const step = { message: 'test' }; 17 | expect(() => { 18 | schema.parse(step); 19 | }).to.throw(Error, `Key 'id' is required in step ${stringify(step)}`); 20 | }); 21 | 22 | it('should throw a key type error', () => { 23 | const step = { id: () => { }, options: [] }; 24 | expect(() => { 25 | schema.parse(step); 26 | }).to.throw(Error, 'The type of \'id\' value must be string or number instead of function'); 27 | }); 28 | 29 | it('should delete a invalid key', () => { 30 | const step = schema.parse({ 31 | id: '1', 32 | message: 'test', 33 | test: 'test', 34 | }); 35 | const resultStep = stringify({ id: '1', message: 'test' }); 36 | expect(stringify(step)).to.be.equal(resultStep); 37 | }); 38 | 39 | it('should not throw error to a user step', () => { 40 | const step = { id: '1', user: true, end: true }; 41 | expect(() => { 42 | schema.parse(step); 43 | }).to.not.throw(); 44 | }); 45 | 46 | it('should not throw error to a component step', () => { 47 | const step = { id: '1', component:
, end: true }; 48 | expect(() => { 49 | schema.parse(step); 50 | }).to.not.throw(); 51 | }); 52 | 53 | it('should not throw error to a update step', () => { 54 | const step = { id: '1', update: '2', trigger: '3' }; 55 | expect(() => { 56 | schema.parse(step); 57 | }).to.not.throw(); 58 | }); 59 | 60 | it('should throw error of inexistent step id', () => { 61 | const steps = { 62 | 1: { 63 | id: '1', 64 | message: 'Test', 65 | trigger: '2', 66 | }, 67 | }; 68 | expect(() => { 69 | schema.checkInvalidIds(steps); 70 | }).to.throw(); 71 | }); 72 | 73 | it('should throw error of inexistent step id in option', () => { 74 | const steps = { 75 | 1: { 76 | id: '1', 77 | options: [ 78 | { label: 'test', value: 'test', trigger: '2' }, 79 | ], 80 | }, 81 | }; 82 | expect(() => { 83 | schema.checkInvalidIds(steps); 84 | }).to.throw(); 85 | }); 86 | 87 | it('should not throw error of inexistent step id', () => { 88 | const steps = { 89 | 1: { 90 | id: '1', 91 | message: 'Test', 92 | trigger: '2', 93 | }, 94 | 2: { 95 | id: '2', 96 | message: 'End', 97 | end: true, 98 | }, 99 | }; 100 | expect(() => { 101 | schema.checkInvalidIds(steps); 102 | }).to.not.throw(); 103 | }); 104 | it('should not throw error with metadata', () => { 105 | const step = { id: '1', message: 'Test', metadata: { data: 'test' } }; 106 | expect(() => { 107 | schema.parse(step); 108 | }).to.not.throw(); 109 | const resultStep = schema.parse(step); 110 | expect(resultStep).to.be.equal(step); 111 | }); 112 | it('should not throw error with inputAttributes', () => { 113 | const step = { id: '1', message: 'Test', inputAttributes: { autoComplete: 'firstname' } }; 114 | expect(() => { 115 | schema.parse(step); 116 | }).to.not.throw(); 117 | const resultStep = schema.parse(step); 118 | expect(resultStep).to.be.equal(step); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /lib/steps_components/text/TextStep.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Bubble from './Bubble'; 4 | import Image from './Image'; 5 | import ImageContainer from './ImageContainer'; 6 | import Loading from '../common/Loading'; 7 | import TextStepContainer from './TextStepContainer'; 8 | 9 | class TextStep extends Component { 10 | /* istanbul ignore next */ 11 | state = { 12 | loading: true 13 | }; 14 | 15 | componentDidMount() { 16 | const { step, speak, previousValue, triggerNextStep } = this.props; 17 | const { component, delay, waitAction } = step; 18 | const isComponentWatingUser = component && waitAction; 19 | 20 | setTimeout(() => { 21 | this.setState({ loading: false }, () => { 22 | if (!isComponentWatingUser && !step.rendered) { 23 | triggerNextStep(); 24 | } 25 | speak(step, previousValue); 26 | }); 27 | }, delay); 28 | } 29 | 30 | getMessage = () => { 31 | const { previousValue, step } = this.props; 32 | const { message } = step; 33 | 34 | return message ? message.replace(/{previousValue}/g, previousValue) : ''; 35 | }; 36 | 37 | renderMessage = () => { 38 | const { step, steps, previousStep, triggerNextStep } = this.props; 39 | const { component } = step; 40 | 41 | if (component) { 42 | return React.cloneElement(component, { 43 | step, 44 | steps, 45 | previousStep, 46 | triggerNextStep 47 | }); 48 | } 49 | 50 | return this.getMessage(); 51 | }; 52 | 53 | render() { 54 | const { 55 | step, 56 | isFirst, 57 | isLast, 58 | avatarStyle, 59 | bubbleStyle, 60 | hideBotAvatar, 61 | hideUserAvatar 62 | } = this.props; 63 | const { loading } = this.state; 64 | const { avatar, user, botName } = step; 65 | 66 | const showAvatar = user ? !hideUserAvatar : !hideBotAvatar; 67 | 68 | const imageAltText = user ? "Your avatar" : `${botName}'s avatar`; 69 | 70 | return ( 71 | 72 | 73 | {isFirst && showAvatar && ( 74 | {imageAltText} 82 | )} 83 | 84 | 92 | {loading ? : this.renderMessage()} 93 | 94 | 95 | ); 96 | } 97 | } 98 | 99 | TextStep.propTypes = { 100 | avatarStyle: PropTypes.objectOf(PropTypes.any).isRequired, 101 | isFirst: PropTypes.bool.isRequired, 102 | isLast: PropTypes.bool.isRequired, 103 | bubbleStyle: PropTypes.objectOf(PropTypes.any).isRequired, 104 | hideBotAvatar: PropTypes.bool.isRequired, 105 | hideUserAvatar: PropTypes.bool.isRequired, 106 | previousStep: PropTypes.objectOf(PropTypes.any), 107 | previousValue: PropTypes.oneOfType([ 108 | PropTypes.string, 109 | PropTypes.bool, 110 | PropTypes.number, 111 | PropTypes.object, 112 | PropTypes.array 113 | ]), 114 | speak: PropTypes.func, 115 | step: PropTypes.objectOf(PropTypes.any).isRequired, 116 | steps: PropTypes.objectOf(PropTypes.any), 117 | triggerNextStep: PropTypes.func.isRequired 118 | }; 119 | 120 | TextStep.defaultProps = { 121 | previousStep: {}, 122 | previousValue: '', 123 | speak: () => {}, 124 | steps: {} 125 | }; 126 | 127 | export default TextStep; 128 | -------------------------------------------------------------------------------- /lib/recognition.js: -------------------------------------------------------------------------------- 1 | let instance = null; 2 | 3 | const noop = () => {}; 4 | 5 | export default class Recognition { 6 | static isSupported() { 7 | return 'webkitSpeechRecognition' in window; 8 | } 9 | 10 | /** 11 | * Creates an instance of Recognition. 12 | * @param {function} [onChange] callback on change 13 | * @param {function} [onEnd] callback on and 14 | * @param {function} [onStop] callback on stop 15 | * @param {string} [lang='en'] recognition lang 16 | * @memberof Recognition 17 | * @constructor 18 | */ 19 | constructor(onChange = noop, onEnd = noop, onStop = noop, lang = 'en') { 20 | if (!instance) { 21 | instance = this; 22 | } 23 | this.state = { 24 | inputValue: '', 25 | lang, 26 | onChange, 27 | onEnd, 28 | onStop 29 | }; 30 | 31 | this.onResult = this.onResult.bind(this); 32 | this.onEnd = this.onEnd.bind(this); 33 | 34 | this.setup(); 35 | 36 | return instance; 37 | } 38 | 39 | /** 40 | * Handler for recognition change event 41 | * @param {string} interimTranscript 42 | * @memberof Recognition 43 | * @private 44 | */ 45 | onChange(interimTranscript) { 46 | const { onChange } = this.state; 47 | this.setState({ 48 | inputValue: interimTranscript 49 | }); 50 | onChange(interimTranscript); 51 | } 52 | 53 | /** 54 | * Handler for recognition change event when its final 55 | * @param {string} finalTranscript 56 | * @memberof Recognition 57 | * @private 58 | */ 59 | onFinal(finalTranscript) { 60 | this.setState({ 61 | inputValue: finalTranscript 62 | }); 63 | this.recognition.stop(); 64 | } 65 | 66 | /** 67 | * Handler for recognition end event 68 | * @memberof Recognition 69 | * @private 70 | */ 71 | onEnd() { 72 | const { onStop, onEnd, force } = this.state; 73 | this.setState({ speaking: false }); 74 | if (force) { 75 | onStop(); 76 | } else { 77 | onEnd(); 78 | } 79 | } 80 | 81 | /** 82 | * Handler for recognition result event 83 | * @memberof Recognition 84 | * @private 85 | */ 86 | onResult(event) { 87 | let interimTranscript = ''; 88 | let finalTranscript = ''; 89 | 90 | for (let i = event.resultIndex; i < event.results.length; i += 1) { 91 | if (event.results[i].isFinal) { 92 | finalTranscript += event.results[i][0].transcript; 93 | this.onFinal(finalTranscript); 94 | } else { 95 | interimTranscript += event.results[i][0].transcript; 96 | this.onChange(interimTranscript); 97 | } 98 | } 99 | } 100 | 101 | /** 102 | * method for updating the instance state 103 | * @param {object} nextState 104 | * @memberof Recognition 105 | * @private 106 | */ 107 | setState(nextState) { 108 | this.state = Object.assign({}, this.state, nextState); 109 | } 110 | 111 | /** 112 | * setup the browser recognition 113 | * @returns {Recognition} 114 | * @memberof Recognition 115 | * @public 116 | */ 117 | setup() { 118 | if (!Recognition.isSupported()) { 119 | return this; 120 | } 121 | 122 | const { webkitSpeechRecognition } = window; 123 | 124 | this.recognition = new webkitSpeechRecognition(); 125 | this.recognition.continuous = true; 126 | this.recognition.interimResults = true; 127 | this.recognition.lang = this.state.lang; 128 | this.recognition.onresult = this.onResult; 129 | this.recognition.onend = this.onEnd; 130 | return this; 131 | } 132 | 133 | /** 134 | * change the recognition lang and resetup 135 | * @param {string} lang the new lang 136 | * @returns {Recognition} 137 | * @memberof Recognition 138 | * @public 139 | */ 140 | setLang(lang) { 141 | this.setState({ lang }); 142 | this.setup(); 143 | return this; 144 | } 145 | 146 | /** 147 | * toggle the recognition 148 | * @returns {Recognition} 149 | * @memberof Recognition 150 | * @public 151 | */ 152 | speak() { 153 | if (!Recognition.isSupported()) { 154 | return this; 155 | } 156 | const { speaking } = this.state; 157 | if (!speaking) { 158 | this.recognition.start(); 159 | this.setState({ 160 | speaking: true, 161 | inputValue: '' 162 | }); 163 | } else { 164 | this.setState({ 165 | force: true 166 | }); 167 | this.recognition.stop(); 168 | } 169 | return this; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/helpers/corti.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //! Corti - Replaces the browser's SpeechRecognition with a fake object. 3 | //! version : 0.2.1 4 | //! author : Tal Ater @TalAter 5 | //! license : MIT 6 | //! https://github.com/TalAter/SpeechKITT/test/corti.js 7 | 8 | // Holds the browser's implementation 9 | const _productionVersion = false; 10 | 11 | // Patch DOMException 12 | var DOMException = DOMException || TypeError; 13 | 14 | // Speech Recognition attributes 15 | let _maxAlternatives = 1; 16 | let _lang = ''; 17 | let _continuous = false; 18 | let _interimResults = false; 19 | 20 | const newSpeechRecognition = function () { 21 | const _self = this; 22 | const _listeners = document.createElement('div'); 23 | _self._started = false; 24 | _self.eventListenerTypes = ['start', 'end', 'result']; 25 | _self.maxAlternatives = 1; 26 | 27 | // Add listeners for events registered through attributes (e.g. recognition.onend = function) and not as proper listeners 28 | _self.eventListenerTypes.forEach((eventName) => { 29 | _listeners.addEventListener( 30 | eventName, 31 | function () { 32 | if (typeof _self[`on${eventName}`] === 'function') { 33 | _self[`on${eventName}`].apply(_listeners, arguments); 34 | } 35 | }, 36 | false, 37 | ); 38 | }); 39 | 40 | Object.defineProperty(this, 'maxAlternatives', { 41 | get() { 42 | return _maxAlternatives; 43 | }, 44 | set(val) { 45 | if (typeof val === 'number') { 46 | _maxAlternatives = Math.floor(val); 47 | } else { 48 | _maxAlternatives = 0; 49 | } 50 | }, 51 | }); 52 | 53 | Object.defineProperty(this, 'lang', { 54 | get() { 55 | return _lang; 56 | }, 57 | set(val) { 58 | if (val === undefined) { 59 | val = 'undefined'; 60 | } 61 | _lang = val.toString(); 62 | }, 63 | }); 64 | 65 | Object.defineProperty(this, 'continuous', { 66 | get() { 67 | return _continuous; 68 | }, 69 | set(val) { 70 | _continuous = Boolean(val); 71 | }, 72 | }); 73 | 74 | Object.defineProperty(this, 'interimResults', { 75 | get() { 76 | return _interimResults; 77 | }, 78 | set(val) { 79 | _interimResults = Boolean(val); 80 | }, 81 | }); 82 | 83 | this.start = function () { 84 | if (_self._started) { 85 | throw new DOMException( 86 | "Failed to execute 'start' on 'SpeechRecognition': recognition has already started.", 87 | ); 88 | } 89 | _self._started = true; 90 | // Create and dispatch an event 91 | const event = document.createEvent('CustomEvent'); 92 | event.initCustomEvent('start', false, false, null); 93 | _listeners.dispatchEvent(event); 94 | }; 95 | 96 | this.abort = function () { 97 | if (!_self._started) { 98 | return; 99 | } 100 | _self._started = false; 101 | // Create and dispatch an event 102 | const event = document.createEvent('CustomEvent'); 103 | event.initCustomEvent('end', false, false, null); 104 | _listeners.dispatchEvent(event); 105 | }; 106 | 107 | this.stop = function () { 108 | return _self.abort(); 109 | }; 110 | 111 | this.isStarted = function () { 112 | return _self._started; 113 | }; 114 | 115 | this.say = function (sentence) { 116 | // Create some speech alternatives 117 | const results = []; 118 | let commandIterator; 119 | let etcIterator; 120 | const itemFunction = function (index) { 121 | if (undefined === index) { 122 | throw new DOMException( 123 | "Failed to execute 'item' on 'SpeechRecognitionResult': 1 argument required, but only 0 present.", 124 | ); 125 | } 126 | index = Number(index); 127 | if (isNaN(index)) { 128 | index = 0; 129 | } 130 | if (index >= this.length) { 131 | return null; 132 | } 133 | return this[index]; 134 | }; 135 | for (commandIterator = 0; commandIterator < _maxAlternatives; commandIterator++) { 136 | let etc = ''; 137 | for (etcIterator = 0; etcIterator < commandIterator; etcIterator++) { 138 | etc += ' and so on'; 139 | } 140 | results.push(sentence + etc); 141 | } 142 | 143 | // Create the event 144 | const event = document.createEvent('CustomEvent'); 145 | event.initCustomEvent('result', false, false, { sentence }); 146 | event.resultIndex = 0; 147 | event.results = { 148 | item: itemFunction, 149 | 0: { 150 | item: itemFunction, 151 | final: true, 152 | }, 153 | }; 154 | for (commandIterator = 0; commandIterator < _maxAlternatives; commandIterator++) { 155 | event.results[0][commandIterator] = { 156 | transcript: results[commandIterator], 157 | confidence: Math.max(1 - 0.01 * commandIterator, 0.001), 158 | }; 159 | } 160 | Object.defineProperty(event.results, 'length', { 161 | get() { 162 | return 1; 163 | }, 164 | }); 165 | Object.defineProperty(event.results[0], 'length', { 166 | get() { 167 | return _maxAlternatives; 168 | }, 169 | }); 170 | event.interpretation = null; 171 | event.emma = null; 172 | _listeners.dispatchEvent(event); 173 | }; 174 | 175 | this.addEventListener = function (event, callback) { 176 | _listeners.addEventListener(event, callback, false); 177 | }; 178 | }; 179 | 180 | export default newSpeechRecognition; 181 | -------------------------------------------------------------------------------- /tests/lib/TextStep.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import { TextStep } from '../../lib/steps_components'; 6 | import Bubble from '../../lib/steps_components/text/Bubble'; 7 | import Image from '../../lib/steps_components/text/Image'; 8 | 9 | const CustomComponent = () => ( 10 |
11 | ); 12 | 13 | describe('TextStep', () => { 14 | describe('Bot text', () => { 15 | const settings = { 16 | step: { 17 | id: '1', 18 | audio: false, 19 | message: 'Hello', 20 | delay: 1000, 21 | bubbleColor: '#eee', 22 | fontColor: '#000', 23 | avatar: '', 24 | }, 25 | isFirst: true, 26 | isLast: true, 27 | hideBotAvatar: false, 28 | hideUserAvatar: false, 29 | avatarStyle: {}, 30 | bubbleStyle: {}, 31 | triggerNextStep: () => {}, 32 | }; 33 | 34 | const wrapper = mount(); 35 | wrapper.setState({ loading: false }); 36 | 37 | it('should render', () => { 38 | expect(wrapper.find(TextStep).length).to.be.equal(1); 39 | }); 40 | 41 | it('should render bubble with background color equal \'#eee\'', () => { 42 | expect(wrapper.props().step.bubbleColor).to.be.equal('#eee'); 43 | }); 44 | 45 | it('should render bubble with font color equal \'#000\'', () => { 46 | expect(wrapper.props().step.fontColor).to.be.equal('#000'); 47 | }); 48 | 49 | it('should render image', () => { 50 | expect(wrapper.find(Image).exists()).to.be.equal(true); 51 | }); 52 | 53 | it('should render bubble with message equal \'Hello\'', () => { 54 | expect(wrapper.find(Bubble).text()).to.be.equal('Hello'); 55 | }); 56 | 57 | it('should render a first bubble (but not last)', () => { 58 | const tsWrapper = mount(); 59 | tsWrapper.setState({ loading: false }); 60 | 61 | expect(tsWrapper.find(Image).exists()).to.be.equal(true); 62 | }); 63 | 64 | it('should render a without avatar', () => { 65 | const tsWrapper = mount( 66 | , 67 | ); 68 | tsWrapper.setState({ loading: false }); 69 | 70 | expect(tsWrapper.find(Image).exists()).to.be.equal(false); 71 | }); 72 | 73 | it('should render a middle bubble', () => { 74 | const tsWrapper = mount(); 75 | tsWrapper.setState({ loading: false }); 76 | 77 | expect(tsWrapper.find(Image).exists()).to.be.equal(false); 78 | }); 79 | }); 80 | 81 | describe('User text', () => { 82 | const settings = { 83 | step: { 84 | id: '1', 85 | message: 'Hello', 86 | delay: 1000, 87 | user: true, 88 | bubbleColor: '#eee', 89 | fontColor: '#000', 90 | avatar: '', 91 | }, 92 | isFirst: false, 93 | isLast: true, 94 | hideBotAvatar: false, 95 | hideUserAvatar: false, 96 | avatarStyle: {}, 97 | bubbleStyle: {}, 98 | triggerNextStep: () => {}, 99 | }; 100 | 101 | const wrapper = mount(); 102 | wrapper.setState({ loading: false }); 103 | 104 | it('should render bubble without avatar (not first)', () => { 105 | expect(wrapper.find(Image).exists()).to.be.equal(false); 106 | }); 107 | 108 | it('should render a first bubble', () => { 109 | const tsWrapper = mount( 110 | , 111 | ); 112 | tsWrapper.setState({ loading: false }); 113 | 114 | expect(tsWrapper.find(Image).exists()).to.be.equal(true); 115 | }); 116 | 117 | it('should render a without avatar', () => { 118 | const tsWrapper = mount( 119 | , 120 | ); 121 | tsWrapper.setState({ loading: false }); 122 | 123 | expect(tsWrapper.find(Image).exists()).to.be.equal(false); 124 | }); 125 | 126 | it('should render a middle bubble', () => { 127 | const tsWrapper = mount( 128 | , 129 | ); 130 | tsWrapper.setState({ loading: false }); 131 | 132 | expect(tsWrapper.find(Image).exists()).to.be.equal(false); 133 | }); 134 | }); 135 | 136 | describe('Function text', () => { 137 | const settings = { 138 | step: { 139 | id: '1', 140 | message: 'Hello', 141 | delay: 1000, 142 | user: true, 143 | bubbleColor: '#eee', 144 | fontColor: '#000', 145 | avatar: '', 146 | }, 147 | isFirst: false, 148 | isLast: true, 149 | hideBotAvatar: false, 150 | hideUserAvatar: false, 151 | avatarStyle: {}, 152 | bubbleStyle: {}, 153 | triggerNextStep: () => {}, 154 | }; 155 | 156 | const wrapper = mount(); 157 | wrapper.setState({ loading: false }); 158 | 159 | it('should render bubble without avatar (not first)', () => { 160 | expect(wrapper.find(Image).exists()).to.be.equal(false); 161 | }); 162 | 163 | it('should render the message "Hello"', () => { 164 | expect(wrapper.find(Bubble).text()).to.be.equal('Hello'); 165 | }); 166 | }); 167 | 168 | describe('Component text', () => { 169 | const settings = { 170 | step: { 171 | id: '1', 172 | component: , 173 | waitAction: true, 174 | }, 175 | isFirst: false, 176 | isLast: true, 177 | hideBotAvatar: false, 178 | hideUserAvatar: false, 179 | avatarStyle: {}, 180 | bubbleStyle: {}, 181 | triggerNextStep: () => {}, 182 | }; 183 | 184 | const wrapper = mount(); 185 | wrapper.setState({ loading: false }); 186 | 187 | it('should render bubble with component', () => { 188 | expect(wrapper.find(CustomComponent).exists()).to.be.equal(true); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /tests/lib/ChatBot.spec.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { describe, it, before, after } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import ChatBot from '../../lib/ChatBot'; 6 | import { 7 | ChatBotContainer, 8 | FloatButton, 9 | Header, 10 | HeaderIcon, 11 | } from '../../lib/components'; 12 | import { CloseIcon } from '../../lib/icons'; 13 | import { TextStep } from '../../lib/steps_components'; 14 | 15 | import { parse } from 'flatted'; 16 | 17 | const CustomComponent = () => ( 18 |
19 | ); 20 | 21 | describe('ChatBot', () => { 22 | describe('Simple', () => { 23 | const wrapper = mount( 24 | { }} 30 | steps={[ 31 | { 32 | id: '1', 33 | message: 'Hello World', 34 | trigger: 'user', 35 | }, 36 | { 37 | id: 'user', 38 | user: true, 39 | trigger: 'update', 40 | }, 41 | { 42 | id: 'update', 43 | update: 'user', 44 | trigger: () => '2', 45 | }, 46 | { 47 | id: '2', 48 | component: , 49 | trigger: '3', 50 | }, 51 | { 52 | id: '3', 53 | component: , 54 | asMessage: true, 55 | trigger: '4', 56 | }, 57 | { 58 | id: '4', 59 | component: , 60 | replace: true, 61 | trigger: '5', 62 | }, 63 | { 64 | id: '5', 65 | options: [ 66 | { value: 'op1', label: 'Option 1', trigger: () => '6' }, 67 | { value: 'op2', label: 'Option 2', trigger: '6' }, 68 | ], 69 | }, 70 | { 71 | id: '6', 72 | message: 'Bye!', 73 | end: true, 74 | }, 75 | ]} 76 | />, 77 | ); 78 | 79 | before((done) => { 80 | wrapper.setState({ inputValue: 'test' }); 81 | wrapper.find('input.rsc-input').simulate('keyPress', { key: 'Enter' }); 82 | 83 | setTimeout(() => { 84 | wrapper.setState({ inputValue: 'test' }); 85 | wrapper.find('input.rsc-input').simulate('keyPress', { key: 'Enter' }); 86 | }, 100); 87 | 88 | setTimeout(() => { 89 | done(); 90 | }, 500); 91 | }); 92 | 93 | after(() => { 94 | wrapper.unmount(); 95 | }); 96 | 97 | it('should render', () => { 98 | expect(wrapper.find(ChatBot).length).to.be.equal(1); 99 | }); 100 | 101 | it('should render with class \'classname-test\'', () => { 102 | expect(wrapper.hasClass('classname-test')).to.be.equal(true); 103 | }); 104 | 105 | it('should render a header', () => { 106 | expect(wrapper.find(Header)).to.have.length(1); 107 | }); 108 | 109 | // it('should render a custom step', () => { 110 | // expect(wrapper.find(CustomStep)).to.have.length(1); 111 | // }); 112 | // 113 | // it('should render a options step', () => { 114 | // expect(wrapper.find(OptionsStep)).to.have.length(1); 115 | // }); 116 | // 117 | // it('should render 5 texts steps', () => { 118 | // wrapper.find('.rsc-os-option-element').first().simulate('click'); 119 | // expect(wrapper.find(TextStep)).to.have.length(5); 120 | // }); 121 | }); 122 | 123 | describe('No Header', () => { 124 | const wrapper = mount( 125 | { }} 131 | steps={[ 132 | { 133 | id: '1', 134 | message: 'Hello World', 135 | end: true, 136 | }, 137 | ]} 138 | />, 139 | ); 140 | 141 | it('should be rendered without header', () => { 142 | expect(wrapper.find(Header)).to.have.length(0); 143 | }); 144 | }); 145 | 146 | describe('Custom Header', () => { 147 | const wrapper = mount( 148 | } 150 | botDelay={0} 151 | userDelay={0} 152 | customDelay={0} 153 | handleEnd={() => { }} 154 | steps={[ 155 | { 156 | id: '1', 157 | message: 'Hello World', 158 | end: true, 159 | }, 160 | ]} 161 | />, 162 | ); 163 | 164 | it('should be rendered with a custom header', () => { 165 | expect(wrapper.find('.header-component')).to.have.length(1); 166 | expect(wrapper.find(Header)).to.have.length(0); 167 | }); 168 | }); 169 | 170 | describe('Floating', () => { 171 | const wrapper = mount( 172 | { }} 179 | steps={[ 180 | { 181 | id: '1', 182 | message: 'Hello World', 183 | trigger: '2', 184 | }, 185 | { 186 | id: '2', 187 | message: () => 'Bye', 188 | end: true, 189 | }, 190 | ]} 191 | />, 192 | ); 193 | 194 | it('should be rendered with floating header', () => { 195 | expect(wrapper.find(Header)).to.have.length(1); 196 | expect(wrapper.find(CloseIcon)).to.have.length(1); 197 | }); 198 | 199 | it('should be rendered with a floating button', () => { 200 | expect(wrapper.find(FloatButton)).to.have.length(1); 201 | }); 202 | 203 | it('should opened the chat when click on floating button', () => { 204 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(false); 205 | wrapper.find(FloatButton).simulate('click'); 206 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(true); 207 | }); 208 | 209 | it('should cache the steps', () => { 210 | const data = parse(localStorage.getItem('rsc_cache')); 211 | expect(data.renderedSteps.length).to.be.equal(2); 212 | }); 213 | }); 214 | 215 | describe('Floating - Custom Opened', () => { 216 | class FloatingExample extends Component { 217 | constructor(props) { 218 | super(props); 219 | 220 | this.state = { 221 | opened: true, 222 | }; 223 | 224 | this.toggleFloating = this.toggleFloating.bind(this); 225 | } 226 | 227 | toggleFloating({ opened }) { 228 | this.setState({ opened }); 229 | } 230 | 231 | render() { 232 | const { opened } = this.state; 233 | return ( 234 | { }} 247 | steps={[ 248 | { 249 | id: '1', 250 | message: 'Hello World', 251 | end: true, 252 | }, 253 | ]} 254 | /> 255 | ); 256 | } 257 | } 258 | 259 | const wrapper = mount(); 260 | 261 | it('should be rendered with floating header', () => { 262 | expect(wrapper.find(Header)).to.have.length(1); 263 | expect(wrapper.find(CloseIcon)).to.have.length(1); 264 | }); 265 | 266 | it('should be rendered with a opened equal true', () => { 267 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(true); 268 | }); 269 | 270 | it('should close the chat when click on close button', () => { 271 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(true); 272 | wrapper.find(HeaderIcon).simulate('click'); 273 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(false); 274 | }); 275 | 276 | it('should opened the chat when click on floating button', () => { 277 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(false); 278 | wrapper.find(FloatButton).simulate('click'); 279 | expect(wrapper.find(ChatBotContainer).props().opened).to.be.equal(true); 280 | }); 281 | 282 | it('should modify the transform-origin style in chatbot container', () => { 283 | expect(wrapper.find(ChatBotContainer).prop('floatingStyle').left).to.be.equal('32px'); 284 | expect(wrapper.find(ChatBotContainer).prop('floatingStyle').right).to.be.equal('initial'); 285 | expect(wrapper.find(ChatBotContainer).prop('floatingStyle').transformOrigin).to.be.equal('bottom left'); 286 | }); 287 | }); 288 | 289 | describe('Hide Input', () => { 290 | const wrapper = mount( 291 | , 301 | ); 302 | 303 | it('should be rendered without input', () => { 304 | expect(wrapper.find('input.rsc-input')).to.have.length(0); 305 | }); 306 | }); 307 | 308 | describe('Metadata', () => { 309 | const wrapper = mount( 310 | (params.steps[1].metadata.custom), 324 | end: true, 325 | }, 326 | ]} 327 | />, 328 | ); 329 | 330 | before(() => { 331 | // Somehow it needs something like this, to wait for the application to be rendered. 332 | // TODO: improve this... 333 | wrapper.simulate('keyPress', { key: 'Enter' }); 334 | }); 335 | 336 | after(() => { 337 | wrapper.unmount(); 338 | }); 339 | 340 | it('should be accessible in "steps" and "previousStep"', () => { 341 | const bubbles = wrapper.find(TextStep); 342 | const step2Bubble = bubbles.at(1); 343 | expect(step2Bubble.props().previousStep.metadata.custom).to.be.equal('Hello World'); 344 | expect(step2Bubble.props().steps[1].metadata.custom).to.be.equal('Hello World'); 345 | }); 346 | 347 | it('should render in second bubble', () => { 348 | const bubbles = wrapper.find(TextStep); 349 | const step2Bubble = bubbles.at(1); 350 | expect(step2Bubble.text()).to.be.equal('Hello World'); 351 | }); 352 | }); 353 | 354 | describe('Input Attributes', () => { 355 | const wrapper = mount( 356 | , 368 | ); 369 | 370 | it('should be rendered with input to autocomplete on \'firstname\'', () => { 371 | expect(wrapper.find('input.rsc-input').props().autoComplete).to.be.equal('firstname'); 372 | }); 373 | }); 374 | 375 | describe('Extra control', () => { 376 | const CustomControl = () => ( 377 | 378 | ); 379 | const wrapper = mount( 380 | } 385 | steps={[ 386 | { 387 | id: '1', 388 | user: true, 389 | hideExtraControl: false, 390 | trigger: '2' 391 | }, 392 | { 393 | id: '2', 394 | user: true, 395 | hideExtraControl: true, 396 | trigger: '3' 397 | }, 398 | { 399 | id: '3', 400 | message: 'end', 401 | end: true 402 | } 403 | ]} 404 | />, 405 | ); 406 | 407 | it('should be rendered with an extra control beside submit button', () => { 408 | expect(wrapper.find('div.rsc-controls button.my-button')).to.have.length(1); 409 | }); 410 | 411 | it('the extra control should be hidden', () => { 412 | console.log("Setting input value"); 413 | wrapper.setState({ inputValue: 'test' }); 414 | console.log("Simulate key press"); 415 | wrapper.find('input.rsc-input').simulate('keyPress', { key: 'Enter' }); 416 | setTimeout(() => { 417 | console.log("testing hidden"); 418 | expect(wrapper.find('div.rsc-controls button.my-button')).to.have.length(0); 419 | }, 500); 420 | }); 421 | 422 | }); 423 | }); 424 | -------------------------------------------------------------------------------- /lib/ChatBot.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Random from 'random-id'; 4 | import { CustomStep, OptionsStep, TextStep } from './steps_components'; 5 | import schema from './schemas/schema'; 6 | import * as storage from './storage'; 7 | import { 8 | ChatBotContainer, 9 | Content, 10 | Header, 11 | HeaderTitle, 12 | HeaderIcon, 13 | FloatButton, 14 | FloatingIcon, 15 | Footer, 16 | Input, 17 | SubmitButton 18 | } from './components'; 19 | import Recognition from './recognition'; 20 | import { ChatIcon, CloseIcon, SubmitIcon, MicIcon } from './icons'; 21 | import { isMobile } from './utils'; 22 | import { speakFn } from './speechSynthesis'; 23 | 24 | class ChatBot extends Component { 25 | /* istanbul ignore next */ 26 | constructor(props) { 27 | super(props); 28 | 29 | this.content = null; 30 | this.input = null; 31 | 32 | this.supportsScrollBehavior = false; 33 | 34 | this.setContentRef = element => { 35 | this.content = element; 36 | }; 37 | 38 | this.setInputRef = element => { 39 | this.input = element; 40 | }; 41 | 42 | this.state = { 43 | renderedSteps: [], 44 | previousSteps: [], 45 | currentStep: {}, 46 | previousStep: {}, 47 | steps: {}, 48 | disabled: true, 49 | opened: props.opened || !props.floating, 50 | inputValue: '', 51 | inputInvalid: false, 52 | speaking: false, 53 | recognitionEnable: props.recognitionEnable && Recognition.isSupported(), 54 | defaultUserSettings: {} 55 | }; 56 | 57 | this.speak = speakFn(props.speechSynthesis); 58 | } 59 | 60 | componentDidMount() { 61 | const { steps } = this.props; 62 | const { 63 | botDelay, 64 | botAvatar, 65 | botName, 66 | cache, 67 | cacheName, 68 | customDelay, 69 | enableMobileAutoFocus, 70 | userAvatar, 71 | userDelay 72 | } = this.props; 73 | const chatSteps = {}; 74 | 75 | const defaultBotSettings = { delay: botDelay, avatar: botAvatar, botName }; 76 | const defaultUserSettings = { 77 | delay: userDelay, 78 | avatar: userAvatar, 79 | hideInput: false, 80 | hideExtraControl: false 81 | }; 82 | const defaultCustomSettings = { delay: customDelay }; 83 | 84 | for (let i = 0, len = steps.length; i < len; i += 1) { 85 | const step = steps[i]; 86 | let settings = {}; 87 | 88 | if (step.user) { 89 | settings = defaultUserSettings; 90 | } else if (step.message || step.asMessage) { 91 | settings = defaultBotSettings; 92 | } else if (step.component) { 93 | settings = defaultCustomSettings; 94 | } 95 | 96 | chatSteps[step.id] = Object.assign({}, settings, schema.parse(step)); 97 | } 98 | 99 | schema.checkInvalidIds(chatSteps); 100 | 101 | const firstStep = steps[0]; 102 | 103 | if (firstStep.message) { 104 | const { message } = firstStep; 105 | firstStep.message = typeof message === 'function' ? message() : message; 106 | chatSteps[firstStep.id].message = firstStep.message; 107 | } 108 | 109 | const { recognitionEnable } = this.state; 110 | const { recognitionLang } = this.props; 111 | 112 | if (recognitionEnable) { 113 | this.recognition = new Recognition( 114 | this.onRecognitionChange, 115 | this.onRecognitionEnd, 116 | this.onRecognitionStop, 117 | recognitionLang 118 | ); 119 | } 120 | 121 | this.supportsScrollBehavior = 'scrollBehavior' in document.documentElement.style; 122 | 123 | if (this.content) { 124 | this.content.addEventListener('DOMNodeInserted', this.onNodeInserted); 125 | window.addEventListener('resize', this.onResize); 126 | } 127 | 128 | const { currentStep, previousStep, previousSteps, renderedSteps } = storage.getData( 129 | { 130 | cacheName, 131 | cache, 132 | firstStep, 133 | steps: chatSteps 134 | }, 135 | () => { 136 | // focus input if last step cached is a user step 137 | this.setState({ disabled: false }, () => { 138 | if (enableMobileAutoFocus || !isMobile()) { 139 | if (this.input) { 140 | this.input.focus(); 141 | } 142 | } 143 | }); 144 | } 145 | ); 146 | 147 | this.setState({ 148 | currentStep, 149 | defaultUserSettings, 150 | previousStep, 151 | previousSteps, 152 | renderedSteps, 153 | steps: chatSteps 154 | }); 155 | } 156 | 157 | static getDerivedStateFromProps(props, state) { 158 | const { opened, toggleFloating } = props; 159 | if (toggleFloating !== undefined && opened !== undefined && opened !== state.opened) { 160 | return { 161 | ...state, 162 | opened 163 | }; 164 | } 165 | return state; 166 | } 167 | 168 | componentWillUnmount() { 169 | if (this.content) { 170 | this.content.removeEventListener('DOMNodeInserted', this.onNodeInserted); 171 | window.removeEventListener('resize', this.onResize); 172 | } 173 | } 174 | 175 | onNodeInserted = event => { 176 | const { currentTarget: target } = event; 177 | const { enableSmoothScroll } = this.props; 178 | 179 | if (enableSmoothScroll && this.supportsScrollBehavior) { 180 | target.scroll({ 181 | top: target.scrollHeight, 182 | left: 0, 183 | behavior: 'smooth' 184 | }); 185 | } else { 186 | target.scrollTop = target.scrollHeight; 187 | } 188 | }; 189 | 190 | onResize = () => { 191 | this.content.scrollTop = this.content.scrollHeight; 192 | }; 193 | 194 | onRecognitionChange = value => { 195 | this.setState({ inputValue: value }); 196 | }; 197 | 198 | onRecognitionEnd = () => { 199 | this.setState({ speaking: false }); 200 | this.handleSubmitButton(); 201 | }; 202 | 203 | onRecognitionStop = () => { 204 | this.setState({ speaking: false }); 205 | }; 206 | 207 | onValueChange = event => { 208 | this.setState({ inputValue: event.target.value }); 209 | }; 210 | 211 | getTriggeredStep = (trigger, value) => { 212 | const steps = this.generateRenderedStepsById(); 213 | return typeof trigger === 'function' ? trigger({ value, steps }) : trigger; 214 | }; 215 | 216 | getStepMessage = message => { 217 | const { previousSteps } = this.state; 218 | const lastStepIndex = previousSteps.length > 0 ? previousSteps.length - 1 : 0; 219 | const steps = this.generateRenderedStepsById(); 220 | const previousValue = previousSteps[lastStepIndex].value; 221 | return typeof message === 'function' ? message({ previousValue, steps }) : message; 222 | }; 223 | 224 | generateRenderedStepsById = () => { 225 | const { previousSteps } = this.state; 226 | const steps = {}; 227 | 228 | for (let i = 0, len = previousSteps.length; i < len; i += 1) { 229 | const { id, message, value, metadata } = previousSteps[i]; 230 | 231 | steps[id] = { 232 | id, 233 | message, 234 | value, 235 | metadata 236 | }; 237 | } 238 | 239 | return steps; 240 | }; 241 | 242 | triggerNextStep = data => { 243 | const { enableMobileAutoFocus } = this.props; 244 | const { defaultUserSettings, previousSteps, renderedSteps, steps } = this.state; 245 | 246 | let { currentStep, previousStep } = this.state; 247 | const isEnd = currentStep.end; 248 | 249 | if (data && data.value) { 250 | currentStep.value = data.value; 251 | } 252 | if (data && data.hideInput) { 253 | currentStep.hideInput = data.hideInput; 254 | } 255 | if (data && data.hideExtraControl) { 256 | currentStep.hideExtraControl = data.hideExtraControl; 257 | } 258 | if (data && data.trigger) { 259 | currentStep.trigger = this.getTriggeredStep(data.trigger, data.value); 260 | } 261 | 262 | if (isEnd) { 263 | this.handleEnd(); 264 | } else if (currentStep.options && data) { 265 | const option = currentStep.options.filter(o => o.value === data.value)[0]; 266 | const trigger = this.getTriggeredStep(option.trigger, currentStep.value); 267 | delete currentStep.options; 268 | 269 | // replace choose option for user message 270 | currentStep = Object.assign({}, currentStep, option, defaultUserSettings, { 271 | user: true, 272 | message: option.label, 273 | trigger 274 | }); 275 | 276 | renderedSteps.pop(); 277 | previousSteps.pop(); 278 | renderedSteps.push(currentStep); 279 | previousSteps.push(currentStep); 280 | 281 | this.setState({ 282 | currentStep, 283 | renderedSteps, 284 | previousSteps 285 | }); 286 | } else if (currentStep.trigger) { 287 | if (currentStep.replace) { 288 | renderedSteps.pop(); 289 | } 290 | 291 | const trigger = this.getTriggeredStep(currentStep.trigger, currentStep.value); 292 | let nextStep = Object.assign({}, steps[trigger]); 293 | 294 | if (nextStep.message) { 295 | nextStep.message = this.getStepMessage(nextStep.message); 296 | } else if (nextStep.update) { 297 | const updateStep = nextStep; 298 | nextStep = Object.assign({}, steps[updateStep.update]); 299 | 300 | if (nextStep.options) { 301 | for (let i = 0, len = nextStep.options.length; i < len; i += 1) { 302 | nextStep.options[i].trigger = updateStep.trigger; 303 | } 304 | } else { 305 | nextStep.trigger = updateStep.trigger; 306 | } 307 | } 308 | 309 | nextStep.key = Random(24); 310 | 311 | previousStep = currentStep; 312 | currentStep = nextStep; 313 | 314 | this.setState({ renderedSteps, currentStep, previousStep }, () => { 315 | if (nextStep.user) { 316 | this.setState({ disabled: false }, () => { 317 | if (enableMobileAutoFocus || !isMobile()) { 318 | if (this.input) { 319 | this.input.focus(); 320 | } 321 | } 322 | }); 323 | } else { 324 | renderedSteps.push(nextStep); 325 | previousSteps.push(nextStep); 326 | 327 | this.setState({ renderedSteps, previousSteps }); 328 | } 329 | }); 330 | } 331 | 332 | const { cache, cacheName } = this.props; 333 | if (cache) { 334 | setTimeout(() => { 335 | storage.setData(cacheName, { 336 | currentStep, 337 | previousStep, 338 | previousSteps, 339 | renderedSteps 340 | }); 341 | }, 300); 342 | } 343 | }; 344 | 345 | handleEnd = () => { 346 | const { handleEnd } = this.props; 347 | 348 | if (handleEnd) { 349 | const { previousSteps } = this.state; 350 | 351 | const renderedSteps = previousSteps.map(step => { 352 | const { id, message, value, metadata } = step; 353 | 354 | return { 355 | id, 356 | message, 357 | value, 358 | metadata 359 | }; 360 | }); 361 | 362 | const steps = []; 363 | 364 | for (let i = 0, len = previousSteps.length; i < len; i += 1) { 365 | const { id, message, value, metadata } = previousSteps[i]; 366 | 367 | steps[id] = { 368 | id, 369 | message, 370 | value, 371 | metadata 372 | }; 373 | } 374 | 375 | const values = previousSteps.filter(step => step.value).map(step => step.value); 376 | 377 | handleEnd({ renderedSteps, steps, values }); 378 | } 379 | }; 380 | 381 | isInputValueEmpty = () => { 382 | const { inputValue } = this.state; 383 | return !inputValue || inputValue.length === 0; 384 | }; 385 | 386 | isLastPosition = step => { 387 | const { renderedSteps } = this.state; 388 | const { length } = renderedSteps; 389 | const stepIndex = renderedSteps.map(s => s.key).indexOf(step.key); 390 | 391 | if (length <= 1 || stepIndex + 1 === length) { 392 | return true; 393 | } 394 | 395 | const nextStep = renderedSteps[stepIndex + 1]; 396 | const hasMessage = nextStep.message || nextStep.asMessage; 397 | 398 | if (!hasMessage) { 399 | return true; 400 | } 401 | 402 | const isLast = step.user !== nextStep.user; 403 | return isLast; 404 | }; 405 | 406 | isFirstPosition = step => { 407 | const { renderedSteps } = this.state; 408 | const stepIndex = renderedSteps.map(s => s.key).indexOf(step.key); 409 | 410 | if (stepIndex === 0) { 411 | return true; 412 | } 413 | 414 | const lastStep = renderedSteps[stepIndex - 1]; 415 | const hasMessage = lastStep.message || lastStep.asMessage; 416 | 417 | if (!hasMessage) { 418 | return true; 419 | } 420 | 421 | const isFirst = step.user !== lastStep.user; 422 | return isFirst; 423 | }; 424 | 425 | handleKeyPress = event => { 426 | if (event.key === 'Enter') { 427 | this.submitUserMessage(); 428 | } 429 | }; 430 | 431 | handleSubmitButton = () => { 432 | const { speaking, recognitionEnable } = this.state; 433 | 434 | if ((this.isInputValueEmpty() || speaking) && recognitionEnable) { 435 | this.recognition.speak(); 436 | if (!speaking) { 437 | this.setState({ speaking: true }); 438 | } 439 | return; 440 | } 441 | 442 | this.submitUserMessage(); 443 | }; 444 | 445 | submitUserMessage = () => { 446 | const { defaultUserSettings, inputValue, previousSteps, renderedSteps } = this.state; 447 | let { currentStep } = this.state; 448 | 449 | const isInvalid = currentStep.validator && this.checkInvalidInput(); 450 | 451 | if (!isInvalid) { 452 | const step = { 453 | message: inputValue, 454 | value: inputValue 455 | }; 456 | 457 | currentStep = Object.assign({}, defaultUserSettings, currentStep, step); 458 | 459 | renderedSteps.push(currentStep); 460 | previousSteps.push(currentStep); 461 | 462 | this.setState( 463 | { 464 | currentStep, 465 | renderedSteps, 466 | previousSteps, 467 | disabled: true, 468 | inputValue: '' 469 | }, 470 | () => { 471 | if (this.input) { 472 | this.input.blur(); 473 | } 474 | } 475 | ); 476 | } 477 | }; 478 | 479 | checkInvalidInput = () => { 480 | const { enableMobileAutoFocus } = this.props; 481 | const { currentStep, inputValue } = this.state; 482 | const result = currentStep.validator(inputValue); 483 | const value = inputValue; 484 | 485 | if (typeof result !== 'boolean' || !result) { 486 | this.setState( 487 | { 488 | inputValue: result.toString(), 489 | inputInvalid: true, 490 | disabled: true 491 | }, 492 | () => { 493 | setTimeout(() => { 494 | this.setState( 495 | { 496 | inputValue: value, 497 | inputInvalid: false, 498 | disabled: false 499 | }, 500 | () => { 501 | if (enableMobileAutoFocus || !isMobile()) { 502 | if (this.input) { 503 | this.input.focus(); 504 | } 505 | } 506 | } 507 | ); 508 | }, 2000); 509 | } 510 | ); 511 | 512 | return true; 513 | } 514 | 515 | return false; 516 | }; 517 | 518 | toggleChatBot = opened => { 519 | const { toggleFloating } = this.props; 520 | 521 | if (toggleFloating) { 522 | toggleFloating({ opened }); 523 | } else { 524 | this.setState({ opened }); 525 | } 526 | }; 527 | 528 | renderStep = (step, index) => { 529 | const { renderedSteps } = this.state; 530 | const { 531 | avatarStyle, 532 | bubbleStyle, 533 | bubbleOptionStyle, 534 | customStyle, 535 | hideBotAvatar, 536 | hideUserAvatar, 537 | speechSynthesis 538 | } = this.props; 539 | const { options, component, asMessage } = step; 540 | const steps = this.generateRenderedStepsById(); 541 | const previousStep = index > 0 ? renderedSteps[index - 1] : {}; 542 | 543 | if (component && !asMessage) { 544 | return ( 545 | 555 | ); 556 | } 557 | 558 | if (options) { 559 | return ( 560 | 567 | ); 568 | } 569 | 570 | return ( 571 | 587 | ); 588 | }; 589 | 590 | render() { 591 | const { 592 | currentStep, 593 | disabled, 594 | inputInvalid, 595 | inputValue, 596 | opened, 597 | renderedSteps, 598 | speaking, 599 | recognitionEnable 600 | } = this.state; 601 | const { 602 | className, 603 | contentStyle, 604 | extraControl, 605 | controlStyle, 606 | floating, 607 | floatingIcon, 608 | floatingStyle, 609 | footerStyle, 610 | headerComponent, 611 | headerTitle, 612 | hideHeader, 613 | hideSubmitButton, 614 | inputStyle, 615 | placeholder, 616 | inputAttributes, 617 | recognitionPlaceholder, 618 | style, 619 | submitButtonStyle, 620 | width, 621 | height 622 | } = this.props; 623 | 624 | const header = headerComponent || ( 625 |
626 | {headerTitle} 627 | {floating && ( 628 | this.toggleChatBot(false)}> 629 | 630 | 631 | )} 632 |
633 | ); 634 | 635 | let customControl; 636 | if (extraControl !== undefined) { 637 | customControl = React.cloneElement(extraControl, { 638 | disabled, 639 | speaking, 640 | invalid: inputInvalid 641 | }); 642 | } 643 | 644 | const icon = 645 | (this.isInputValueEmpty() || speaking) && recognitionEnable ? : ; 646 | 647 | const inputPlaceholder = speaking 648 | ? recognitionPlaceholder 649 | : currentStep.placeholder || placeholder; 650 | 651 | const inputAttributesOverride = currentStep.inputAttributes || inputAttributes; 652 | 653 | return ( 654 |
655 | {floating && ( 656 | this.toggleChatBot(true)} 661 | > 662 | {typeof floatingIcon === 'string' ? : floatingIcon} 663 | 664 | )} 665 | 674 | {!hideHeader && header} 675 | 683 | {renderedSteps.map(this.renderStep)} 684 | 685 |
686 | {!currentStep.hideInput && ( 687 | 702 | )} 703 |
704 | {!currentStep.hideInput && !currentStep.hideExtraControl && customControl} 705 | {!currentStep.hideInput && !hideSubmitButton && ( 706 | 714 | {icon} 715 | 716 | )} 717 |
718 |
719 |
720 |
721 | ); 722 | } 723 | } 724 | 725 | ChatBot.propTypes = { 726 | avatarStyle: PropTypes.objectOf(PropTypes.any), 727 | botAvatar: PropTypes.string, 728 | botName: PropTypes.string, 729 | botDelay: PropTypes.number, 730 | bubbleOptionStyle: PropTypes.objectOf(PropTypes.any), 731 | bubbleStyle: PropTypes.objectOf(PropTypes.any), 732 | cache: PropTypes.bool, 733 | cacheName: PropTypes.string, 734 | className: PropTypes.string, 735 | contentStyle: PropTypes.objectOf(PropTypes.any), 736 | customDelay: PropTypes.number, 737 | customStyle: PropTypes.objectOf(PropTypes.any), 738 | controlStyle: PropTypes.objectOf(PropTypes.any), 739 | enableMobileAutoFocus: PropTypes.bool, 740 | enableSmoothScroll: PropTypes.bool, 741 | extraControl: PropTypes.objectOf(PropTypes.element), 742 | floating: PropTypes.bool, 743 | floatingIcon: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 744 | floatingStyle: PropTypes.objectOf(PropTypes.any), 745 | footerStyle: PropTypes.objectOf(PropTypes.any), 746 | handleEnd: PropTypes.func, 747 | headerComponent: PropTypes.element, 748 | headerTitle: PropTypes.string, 749 | height: PropTypes.string, 750 | hideBotAvatar: PropTypes.bool, 751 | hideHeader: PropTypes.bool, 752 | hideSubmitButton: PropTypes.bool, 753 | hideUserAvatar: PropTypes.bool, 754 | inputAttributes: PropTypes.objectOf(PropTypes.any), 755 | inputStyle: PropTypes.objectOf(PropTypes.any), 756 | opened: PropTypes.bool, 757 | toggleFloating: PropTypes.func, 758 | placeholder: PropTypes.string, 759 | recognitionEnable: PropTypes.bool, 760 | recognitionLang: PropTypes.string, 761 | recognitionPlaceholder: PropTypes.string, 762 | speechSynthesis: PropTypes.shape({ 763 | enable: PropTypes.bool, 764 | lang: PropTypes.string, 765 | voice: 766 | typeof window !== 'undefined' 767 | ? PropTypes.instanceOf(window.SpeechSynthesisVoice) 768 | : PropTypes.any 769 | }), 770 | steps: PropTypes.arrayOf(PropTypes.object).isRequired, 771 | style: PropTypes.objectOf(PropTypes.any), 772 | submitButtonStyle: PropTypes.objectOf(PropTypes.any), 773 | userAvatar: PropTypes.string, 774 | userDelay: PropTypes.number, 775 | width: PropTypes.string 776 | }; 777 | 778 | ChatBot.defaultProps = { 779 | avatarStyle: {}, 780 | botDelay: 1000, 781 | botName: 'The bot', 782 | bubbleOptionStyle: {}, 783 | bubbleStyle: {}, 784 | cache: false, 785 | cacheName: 'rsc_cache', 786 | className: '', 787 | contentStyle: {}, 788 | customStyle: {}, 789 | controlStyle: { position: 'absolute', right: '0', top: '0' }, 790 | customDelay: 1000, 791 | enableMobileAutoFocus: false, 792 | enableSmoothScroll: false, 793 | extraControl: undefined, 794 | floating: false, 795 | floatingIcon: , 796 | floatingStyle: {}, 797 | footerStyle: {}, 798 | handleEnd: undefined, 799 | headerComponent: undefined, 800 | headerTitle: 'Chat', 801 | height: '520px', 802 | hideBotAvatar: false, 803 | hideHeader: false, 804 | hideSubmitButton: false, 805 | hideUserAvatar: false, 806 | inputStyle: {}, 807 | opened: undefined, 808 | placeholder: 'Type the message ...', 809 | inputAttributes: {}, 810 | recognitionEnable: false, 811 | recognitionLang: 'en', 812 | recognitionPlaceholder: 'Listening ...', 813 | speechSynthesis: { 814 | enable: false, 815 | lang: 'en', 816 | voice: null 817 | }, 818 | style: {}, 819 | submitButtonStyle: {}, 820 | toggleFloating: undefined, 821 | userDelay: 1000, 822 | width: '350px', 823 | botAvatar: 824 | "data:image/svg+xml,%3csvg version='1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cpath d='M303 70a47 47 0 1 0-70 40v84h46v-84c14-8 24-23 24-40z' fill='%2393c7ef'/%3e%3cpath d='M256 23v171h23v-84a47 47 0 0 0-23-87z' fill='%235a8bb0'/%3e%3cpath fill='%2393c7ef' d='M0 240h248v124H0z'/%3e%3cpath fill='%235a8bb0' d='M264 240h248v124H264z'/%3e%3cpath fill='%2393c7ef' d='M186 365h140v124H186z'/%3e%3cpath fill='%235a8bb0' d='M256 365h70v124h-70z'/%3e%3cpath fill='%23cce9f9' d='M47 163h419v279H47z'/%3e%3cpath fill='%2393c7ef' d='M256 163h209v279H256z'/%3e%3cpath d='M194 272a31 31 0 0 1-62 0c0-18 14-32 31-32s31 14 31 32z' fill='%233c5d76'/%3e%3cpath d='M380 272a31 31 0 0 1-62 0c0-18 14-32 31-32s31 14 31 32z' fill='%231e2e3b'/%3e%3cpath d='M186 349a70 70 0 1 0 140 0H186z' fill='%233c5d76'/%3e%3cpath d='M256 349v70c39 0 70-31 70-70h-70z' fill='%231e2e3b'/%3e%3c/svg%3e", 825 | userAvatar: 826 | "data:image/svg+xml,%3csvg viewBox='-208.5 21 100 100' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ccircle cx='-158.5' cy='71' fill='%23F5EEE5' r='50'/%3e%3cdefs%3e%3ccircle cx='-158.5' cy='71' id='a' r='50'/%3e%3c/defs%3e%3cclipPath id='b'%3e%3cuse overflow='visible' xlink:href='%23a'/%3e%3c/clipPath%3e%3cpath clip-path='url(%23b)' d='M-108.5 121v-14s-21.2-4.9-28-6.7c-2.5-.7-7-3.3-7-12V82h-30v6.3c0 8.7-4.5 11.3-7 12-6.8 1.9-28.1 7.3-28.1 6.7v14h100.1z' fill='%23E6C19C'/%3e%3cg clip-path='url(%23b)'%3e%3cdefs%3e%3cpath d='M-108.5 121v-14s-21.2-4.9-28-6.7c-2.5-.7-7-3.3-7-12V82h-30v6.3c0 8.7-4.5 11.3-7 12-6.8 1.9-28.1 7.3-28.1 6.7v14h100.1z' id='c'/%3e%3c/defs%3e%3cclipPath id='d'%3e%3cuse overflow='visible' xlink:href='%23c'/%3e%3c/clipPath%3e%3cpath clip-path='url(%23d)' d='M-158.5 100.1c12.7 0 23-18.6 23-34.4 0-16.2-10.3-24.7-23-24.7s-23 8.5-23 24.7c0 15.8 10.3 34.4 23 34.4z' fill='%23D4B08C'/%3e%3c/g%3e%3cpath d='M-158.5 96c12.7 0 23-16.3 23-31 0-15.1-10.3-23-23-23s-23 7.9-23 23c0 14.7 10.3 31 23 31z' fill='%23F2CEA5'/%3e%3c/svg%3e" 827 | }; 828 | 829 | export default ChatBot; 830 | -------------------------------------------------------------------------------- /dist/react-simple-chatbot.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("react"),require("styled-components")):"function"==typeof define&&define.amd?define(["react","styled-components"],t):"object"==typeof exports?exports.ReactSimpleChatbot=t(require("react"),require("styled-components")):e.ReactSimpleChatbot=t(e.react,e["styled-components"])}("undefined"!=typeof self?self:this,function(e,t){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="dist/",n(n.s=9)}([function(e,t,n){e.exports=n(5)()},function(t,n){t.exports=e},function(e,n){e.exports=t},function(e,t){var n=function(e,t){return{parse:function(t,r){var a=JSON.parse(t,i).map(o),s=a[0],u=r||n,c="object"==typeof s&&s?function t(n,r,o,i){return Object.keys(o).reduce(function(o,a){var s=o[a];if(s instanceof e){var u=n[s];"object"!=typeof u||r.has(u)?o[a]=i.call(o,a,u):(r.add(u),o[a]=i.call(o,a,t(n,r,u,i)))}else o[a]=i.call(o,a,s);return o},o)}(a,new Set,s,u):s;return u.call({"":c},"",c)},stringify:function(e,o,i){for(var a,s=new Map,u=[],c=[],l=o&&typeof o==typeof u?function(e,t){if(""===e||-11&&void 0!==arguments[1]?arguments[1]:1,n=function(e){e=e.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i,function(e,t,n,r){return t+t+n+n+r+r});var t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null}(e);return"rgba(".concat(n.r,", ").concat(n.g,", ").concat(n.b,", ").concat(t,")")};function f(){var e=g(["\n 0% { box-shadow: 0 0 0 0 ","; }\n 70% { box-shadow: 0 0 0 10px ","; }\n 100% { box-shadow: 0 0 0 0 ","; }\n"]);return f=function(){return e},e}function d(){var e=g(["\n 25% { transform: rotate(-1deg); }\n 100% { transform: rotate(1deg); }\n"]);return d=function(){return e},e}function b(){var e=g(["\n 100% { transform: scale(1); }\n"]);return b=function(){return e},e}function h(){var e=g(["\n 0% { opacity: .2; }\n 20% { opacity: 1; }\n 100% { opacity: .2; }\n"]);return h=function(){return e},e}function g(e,t){return t||(t=e.slice(0)),Object.freeze(Object.defineProperties(e,{raw:{value:Object.freeze(t)}}))}var y=Object(c.keyframes)(h()),v=Object(c.keyframes)(b()),m=Object(c.keyframes)(d());function S(){var e=function(e,t){t||(t=e.slice(0));return Object.freeze(Object.defineProperties(e,{raw:{value:Object.freeze(t)}}))}(["\n animation: "," 1.4s infinite both;\n animation-delay: ",";\n"]);return S=function(){return e},e}var x=l.a.span(S(),y,function(e){return e.delay}),O=function(){return o.a.createElement("span",{className:"rsc-loading"},o.a.createElement(x,{delay:"0s"},"."),o.a.createElement(x,{delay:".2s"},"."),o.a.createElement(x,{delay:".4s"},"."))};function w(){var e=function(e,t){t||(t=e.slice(0));return Object.freeze(Object.defineProperties(e,{raw:{value:Object.freeze(t)}}))}(["\n background: #fff;\n border-radius: 5px;\n box-shadow: rgba(0, 0, 0, 0.15) 0px 1px 2px 0px;\n display: flex;\n justify-content: center;\n margin: 0 6px 10px 6px;\n padding: 16px;\n"]);return w=function(){return e},e}var j=l.a.div(w());function k(e){return(k="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function E(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:nt,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:nt,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:nt,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"en";return function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),tt||(tt=this),this.state={inputValue:"",lang:o,onChange:t,onEnd:n,onStop:r},this.onResult=this.onResult.bind(this),this.onEnd=this.onEnd.bind(this),this.setup(),tt}return et(e,null,[{key:"isSupported",value:function(){return"webkitSpeechRecognition"in window}}]),et(e,[{key:"onChange",value:function(e){var t=this.state.onChange;this.setState({inputValue:e}),t(e)}},{key:"onFinal",value:function(e){this.setState({inputValue:e}),this.recognition.stop()}},{key:"onEnd",value:function(){var e=this.state,t=e.onStop,n=e.onEnd,r=e.force;this.setState({speaking:!1}),r?t():n()}},{key:"onResult",value:function(e){for(var t="",n="",r=e.resultIndex;r0?t.length-1:0,o=n.generateRenderedStepsById(),i=t[r].value;return"function"==typeof e?e({previousValue:i,steps:o}):e}),mt(vt(vt(n)),"generateRenderedStepsById",function(){for(var e=n.state.previousSteps,t={},r=0,o=e.length;r0?r[t-1]:{};return b&&!h?o.a.createElement(R,{key:t,speak:n.speak,step:e,steps:g,style:c,previousStep:y,previousValue:y.value,triggerNextStep:n.triggerNextStep}):d?o.a.createElement(Z,{key:t,step:e,previousValue:y.value,triggerNextStep:n.triggerNextStep,bubbleOptionStyle:u}):o.a.createElement(de,{key:t,step:e,steps:g,speak:n.speak,previousStep:y,previousValue:y.value,triggerNextStep:n.triggerNextStep,avatarStyle:a,bubbleStyle:s,hideBotAvatar:l,hideUserAvatar:p,speechSynthesis:f,isFirst:n.isFirstPosition(e),isLast:n.isLastPosition(e)})}),n.content=null,n.input=null,n.supportsScrollBehavior=!1,n.setContentRef=function(e){n.content=e},n.setInputRef=function(e){n.input=e},n.state={renderedSteps:[],previousSteps:[],currentStep:{},previousStep:{},steps:{},disabled:!0,opened:e.opened||!e.floating,inputValue:"",inputInvalid:!1,speaking:!1,recognitionEnable:e.recognitionEnable&&rt.isSupported(),defaultUserSettings:{}},n.speak=ft(e.speechSynthesis),n}var n,i,a;return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),t&&yt(e,t)}(t,r["Component"]),n=t,a=[{key:"getDerivedStateFromProps",value:function(e,t){var n=e.opened;return void 0!==e.toggleFloating&&void 0!==n&&n!==t.opened?function(e){for(var t=1;t