├── 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 |
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 |
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 |
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 |
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 |
9 |
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 | | |
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 |
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 |
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