From eb3d2cfef57ac6370f873c8e37c6563f80d36161 Mon Sep 17 00:00:00 2001 From: Lucas Bassetti Date: Sat, 26 Sep 2026 18:57:47 -0300 Subject: [PATCH 1/6] Maintenance: fix duplicated messages, autoscroll, React 18/19 and styled-components 6 support (#392) * Fix Recognition returning the first instance for every new instance The module-level singleton made every new Recognition return the first one, so a remounted or second ChatBot kept calling the callbacks of the first (possibly unmounted) component. * Wait for input when the first step is a user step; guard localStorage - A first user step was rendered as an empty bubble and immediately triggered the next step without waiting for the user. - Only read localStorage when cache is enabled and tolerate storage that throws (Safari private mode, quota exceeded). Ports #351. * Modernize toolchain: webpack 5, Babel 7.29, ESLint 8, mocha 10, React 18 tests - Move react/react-dom and lint tooling out of runtime dependencies - Widen peer ranges to react/react-dom >=16.3 and styled-components >=4 - Replace Travis/Node 8 CI with GitHub Actions on Node 20/22/24 - Stop tracking dist/ and coverage.lcov (dist is built on publish) - Fix flatted import path for flatted 3 * Fix duplicated messages: trigger each step only once Steps started their delay timer in componentDidMount and never cleared it, so React 18 StrictMode (mount, unmount, mount) triggered every step twice and the conversation doubled at each step. Custom components that called triggerNextStep more than once and double clicks on options had the same effect. - clear the step timer on unmount - let each step trigger the next step at most once - ignore repeated clicks on an options step * Fix autoscroll on modern browsers and several small bugs - Replace the removed DOMNodeInserted event with a MutationObserver, so the conversation scrolls to the last message again (Chrome 127+) - Don't restart speech recognition forever when it ends with no text - Don't access window when checking recognition support (SSR) - Copy options of update steps instead of changing the original step - Fix extraControl prop type (a single element, not an object of elements) - Use default parameters instead of defaultProps in icons (React 19) * Support styled-components 6 without leaking style props to the DOM styled-components 6 forwards every prop to DOM elements, which made React warn about user, isFirst, floating, opened, invalid and others. Filter the style-only props with shouldForwardProp; styled-components 4 and 5.0 ignore the option and keep filtering them as before. Tested with styled-components 4.4, 5.3 and 6.5. * Don't crash the mic button with non-hex theme colors rgba() returned null for named, rgb() or hsl() colors and the pulse animation threw. Fall back to color-mix() for those colors. * Apply steps prop updates and fix several step bugs - Rebuild the step definitions when steps, avatars, delays or botName change, so steps that were not rendered yet use the new values - Select options without a value by their label (all of them matched the first option before) - Start the conversation from the parsed first step, so it gets the default settings too, and don't mutate the steps prop - Render DOM element components (e.g.
) without injecting step props, which React warned about - Don't speak cached steps again, and read speechSynthesis on every message so it can be toggled after mount - Ignore triggerNextStep calls with a value that matches no option * Only autoscroll when the user is at the bottom; improve accessibility Autoscroll: - scroll to the bottom on every new message - for other DOM changes (loading bubbles, tooltips, ripples, content loaded inside custom steps) only scroll if the user didn't scroll up Accessibility (based on #293): - label the input, the submit/voice button and the open/close buttons - make the open/close buttons focusable and usable with the keyboard - use type="button" so the chatbot doesn't submit a surrounding form - use a valid input type and fix the invalid avatar width (40 -> 40px) * Add TypeScript declarations Types for the ChatBot props, every step kind, trigger/message functions, handleEnd and the props injected in custom step components. Checked in CI with tsc against tests/types. * Start a closed floating chatbot when it is opened The conversation used to run while the floating chatbot was still closed, so users opened it to find every message already there. * Filter the loading delay prop and update the example app - LoadingStep leaked its delay prop to the DOM with styled-components 6 - The example uses createRoot and StrictMode and a real conversation (input, options without value and a custom step) * Run the createRoot tests with React 19 in CI * Update README: install styled-components, supported versions and tips Also points the website and author links to the current domains (same change as #391). * Publish an ES5 bundle and stop tracking dist - Build for ES5 (including flatted) so webpack 4 and older browsers can parse the bundle, as before - Stop tracking dist/ and build it in prepare, so installs from git get a fresh bundle - Remove engines, it would block installs on older Node versions * Fix issues found in review - Build steps again lazily, when a step is triggered, instead of on every parent render; keep the last valid steps if the new ones are invalid - Submit the recognized text when voice recognition ends (the batched speaking state restarted the recognition instead under createRoot) - Keep the value of a first custom step and evaluate its message on a copy, so looping back to it evaluates the message again - Restore option values from old caches - Don't focus the input of a closed floating chatbot - Allow boolean validators in the TypeScript declarations * Add CAMARADES BR to the Build with list (from #347) Co-authored-by: vyk1 <47799125+vyk1@users.noreply.github.com> * Release 0.7.0 --------- Co-authored-by: vyk1 <47799125+vyk1@users.noreply.github.com> --- .eslintrc | 21 +- .github/workflows/nodejs.yml | 44 +- .gitignore | 6 + .prettierrc | 6 +- .travis.yml | 5 - README.md | 23 +- babel.config.js | 12 +- coverage.lcov | 0 dist/react-simple-chatbot.js | 2 - dist/react-simple-chatbot.js.map | 1 - example/components/Example.jsx | 25 +- example/main.jsx | 10 +- index.d.ts | 152 + lib/ChatBot.jsx | 273 +- lib/common/rgba.js | 8 +- lib/common/withConfig.js | 24 + lib/components/ChatBotContainer.jsx | 3 +- lib/components/Content.jsx | 3 +- lib/components/FloatButton.jsx | 3 +- lib/components/Input.jsx | 5 +- lib/components/SubmitButton.jsx | 3 +- lib/icons/MicIcon.jsx | 6 +- lib/icons/SubmitIcon.jsx | 6 +- lib/recognition.js | 9 +- lib/schemas/schema.js | 2 +- lib/steps_components/common/LoadingStep.jsx | 3 +- lib/steps_components/custom/CustomStep.jsx | 35 +- .../options/OptionElement.jsx | 5 +- lib/steps_components/options/OptionsStep.jsx | 10 +- lib/steps_components/text/Bubble.jsx | 3 +- lib/steps_components/text/Image.jsx | 6 +- lib/steps_components/text/ImageContainer.jsx | 3 +- lib/steps_components/text/TextStep.jsx | 47 +- .../text/TextStepContainer.jsx | 3 +- lib/storage.js | 52 +- package-lock.json | 23403 ++++++++-------- package.json | 110 +- tests/helpers/setup-dom.js | 3 + tests/helpers/setup.js | 37 +- tests/lib/ChatBot.spec.js | 18 +- tests/lib/Regressions.spec.js | 283 + tests/lib/StrictMode.spec.js | 105 + tests/lib/recognition.spec.js | 19 +- tests/lib/rgba.spec.js | 6 + tests/types/index.tsx | 41 + tests/types/tsconfig.json | 13 + webpack.config.js | 12 +- webpack.config.prod.js | 50 +- 48 files changed, 13522 insertions(+), 11397 deletions(-) delete mode 100644 .travis.yml delete mode 100644 coverage.lcov delete mode 100644 dist/react-simple-chatbot.js delete mode 100644 dist/react-simple-chatbot.js.map create mode 100644 index.d.ts create mode 100644 lib/common/withConfig.js create mode 100644 tests/helpers/setup-dom.js create mode 100644 tests/lib/Regressions.spec.js create mode 100644 tests/lib/StrictMode.spec.js create mode 100644 tests/types/index.tsx create mode 100644 tests/types/tsconfig.json diff --git a/.eslintrc b/.eslintrc index ea7803a7..89f092f8 100755 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,8 @@ { - "extends": ["airbnb", "plugin:prettier/recommended", "prettier/react"], - "plugins": ["prettier"], - "parser": "babel-eslint", - "globals": { - "window": true, - "Audio": true, - "document": true, - "localStorage": true, - "navigator": true + "extends": ["airbnb", "airbnb/hooks", "plugin:prettier/recommended"], + "parser": "@babel/eslint-parser", + "env": { + "browser": true }, "rules": { "prettier/prettier": ["error"], @@ -17,6 +12,14 @@ "no-restricted-syntax": 0, "no-param-reassign": 0, "react/no-will-update-set-state": 0, + "react/forbid-prop-types": 0, + "react/jsx-props-no-spreading": 0, + "react/function-component-definition": 0, + "react/require-default-props": 0, + "react/static-property-placement": 0, + "react/state-in-constructor": 0, + "react/destructuring-assignment": 0, + "prefer-object-spread": 0, "new-cap": 0 } } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 63188989..b226b97f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,26 +1,40 @@ name: Node CI -on: [push] +on: + push: + branches: [master] + pull_request: jobs: build: - runs-on: ubuntu-latest strategy: matrix: - node-version: [8.x, 10.x, 12.x] + node-version: [20.x, 22.x, 24.x] + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run test:coverage + - run: npm run test:types + - run: npm run build + + react-19: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: npm install, build, and test - run: | - npm ci - npm run build --if-present - npm test - env: - CI: true + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: npm + - run: npm ci + - run: npm install --no-save --legacy-peer-deps react@19 react-dom@19 + - run: npm run test:dom diff --git a/.gitignore b/.gitignore index 0c5e34ea..0a1fcda5 100755 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ pids # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules + +# Coverage report +coverage.lcov + +# Build output (generated on publish) +/dist diff --git a/.prettierrc b/.prettierrc index ea9ebdb2..31fb5177 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,6 @@ { "printWidth": 100, - "singleQuote": true -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "none", + "arrowParens": "avoid" +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a5d9e581..00000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -node_js: - - 8 -before_install: - - "npm install react@16.4.1 react-dom@16.4.1 styled-components@4.1.3" diff --git a/README.md b/README.md index 2f104f26..351af461 100755 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # React Simple Chatbot -Travis CI npm version +Node CI npm version Codecov @@ -15,15 +15,20 @@ A simple chatbot component to create conversation chats ## Getting Start +`react-simple-chatbot` uses [styled-components](https://styled-components.com) as a peer dependency, so install both: + ```bash -npm install react-simple-chatbot --save +npm install react-simple-chatbot styled-components ``` +It works with React 16.3 or newer (including React 18 and 19) and styled-components 4, 5 or 6. TypeScript declarations are included. + ## Usage There are several examples on the [website](https://lucasbassetti.github.io/react-simple-chatbot/). Here is the first one to get you started: ``` javascript +import { createRoot } from 'react-dom/client'; import ChatBot from 'react-simple-chatbot'; const steps = [ @@ -39,14 +44,15 @@ const steps = [ }, ]; -ReactDOM.render( -
- -
, - document.getElementById('root') -); +createRoot(document.getElementById('root')).render(); ``` +### Tips + +- **Restart the conversation**: render the chatbot with a new `key` (e.g. ``). If `cache` is enabled, also clear it with `localStorage.removeItem(cacheName)`. +- **Change steps after mount**: updates to `steps`, the avatars, the delays and `botName` are used by the steps that were not rendered yet. +- **Style the chatbot**: every element has a stable `rsc-*` class name (e.g. `.rsc-ts-bubble`, `.rsc-input`) that you can target with CSS, and the `*Style` props accept inline styles. + ## React Simple Chatbot with AI 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. @@ -64,6 +70,7 @@ ReactDOM.render( 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. 4. [Chatcompose](https://www.chatcompose.com/en.html) - Chatbot Platform for Conversational Marketing and Support. 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)) +6. [CAMARADES BR](https://camaradesbrasil.bio.br/) - CAMARADES' brazilian research group Built something with `react-simple-chatbot`? Submit a PR and add it to this list! diff --git a/babel.config.js b/babel.config.js index af37e236..aa5722ae 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,8 +1,4 @@ -const presets = ['@babel/env', '@babel/preset-react']; -const plugins = [ - '@babel/plugin-transform-arrow-functions', - '@babel/plugin-proposal-class-properties', - '@babel/plugin-transform-object-assign' -]; - -module.exports = { presets, plugins }; +module.exports = { + // the published bundle is ES5, so older bundlers (webpack 4) and browsers can parse it + presets: [['@babel/preset-env', { targets: 'ie 11' }], '@babel/preset-react'] +}; diff --git a/coverage.lcov b/coverage.lcov deleted file mode 100644 index e69de29b..00000000 diff --git a/dist/react-simple-chatbot.js b/dist/react-simple-chatbot.js deleted file mode 100644 index 0e388b2d..00000000 --- a/dist/react-simple-chatbot.js +++ /dev/null @@ -1,2 +0,0 @@ -!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 {\n // http://stackoverflow.com/a/5624139\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n};\n\n/**\n * Transform hex+alpha to rgba\n * @param {string} hex hex color code\n * @param {number} [alpha=1]\n * @returns {string} the rgba as string\n */\nconst rgba = (hex, alpha = 1) => {\n const color = hexToRgb(hex);\n return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;\n};\n\nexport default rgba;\n","import { keyframes } from 'styled-components';\nimport rgba from './rgba';\n\nconst loading = keyframes`\n 0% { opacity: .2; }\n 20% { opacity: 1; }\n 100% { opacity: .2; }\n`;\n\nconst scale = keyframes`\n 100% { transform: scale(1); }\n`;\n\nconst invalidInput = keyframes`\n 25% { transform: rotate(-1deg); }\n 100% { transform: rotate(1deg); }\n`;\n\nconst pulse = color => keyframes`\n 0% { box-shadow: 0 0 0 0 ${rgba(color, 0.4)}; }\n 70% { box-shadow: 0 0 0 10px ${rgba(color, 0)}; }\n 100% { box-shadow: 0 0 0 0 ${rgba(color, 0)}; }\n`;\n\nexport { loading, scale, invalidInput, pulse };\n","import styled from 'styled-components';\nimport { loading } from '../../common/animations';\n\nconst LoadingStep = styled.span`\n animation: ${loading} 1.4s infinite both;\n animation-delay: ${props => props.delay};\n`;\n\nexport default LoadingStep;\n","import React from 'react';\nimport LoadingStep from './LoadingStep';\n\nconst Loading = () => (\n \n .\n .\n .\n \n);\n\nexport default Loading;\n","import styled from 'styled-components';\n\nconst ChatStepContainer = styled.div`\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`;\n\nexport default ChatStepContainer;\n","import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport Loading from '../common/Loading';\nimport CustomStepContainer from './CustomStepContainer';\n\nclass CustomStep extends Component {\n state = {\n loading: true\n };\n\n componentDidMount() {\n const { speak, step, previousValue, triggerNextStep } = this.props;\n const { delay, waitAction } = step;\n\n setTimeout(() => {\n this.setState({ loading: false }, () => {\n if (!waitAction && !step.rendered) {\n triggerNextStep();\n }\n speak(step, previousValue);\n });\n }, delay);\n }\n\n renderComponent = () => {\n const { step, steps, previousStep, triggerNextStep } = this.props;\n const { component } = step;\n\n return React.cloneElement(component, {\n step,\n steps,\n previousStep,\n triggerNextStep\n });\n };\n\n render() {\n const { loading } = this.state;\n const { style } = this.props;\n\n return (\n \n {loading ? : this.renderComponent()}\n \n );\n }\n}\n\nCustomStep.propTypes = {\n previousStep: PropTypes.objectOf(PropTypes.any).isRequired,\n previousValue: PropTypes.oneOfType([\n PropTypes.string,\n PropTypes.bool,\n PropTypes.number,\n PropTypes.object,\n PropTypes.array\n ]),\n speak: PropTypes.func,\n step: PropTypes.objectOf(PropTypes.any).isRequired,\n steps: PropTypes.objectOf(PropTypes.any).isRequired,\n style: PropTypes.objectOf(PropTypes.any).isRequired,\n triggerNextStep: PropTypes.func.isRequired\n};\nCustomStep.defaultProps = {\n previousValue: '',\n speak: () => {}\n};\n\nexport default CustomStep;\n","import styled from 'styled-components';\nimport { scale } from '../../common/animations';\n\nconst Option = styled.li`\n animation: ${scale} 0.3s ease forwards;\n cursor: pointer;\n display: inline-block;\n margin: 2px;\n transform: scale(0);\n`;\n\nexport default Option;\n","export default {\n background: '#f5f8fb',\n fontFamily: 'monospace',\n headerBgColor: '#6e48aa',\n headerFontColor: '#fff',\n headerFontSize: '16px',\n botBubbleColor: '#6E48AA',\n botFontColor: '#fff',\n userBubbleColor: '#fff',\n userFontColor: '#4a4a4a'\n};\n","import styled from 'styled-components';\nimport defaultTheme from '../../theme';\n\nconst OptionElement = styled.button`\n background: ${({ theme }) => theme.botBubbleColor};\n border: 0;\n border-radius: 22px;\n box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15);\n color: ${({ theme }) => theme.botFontColor};\n display: inline-block;\n font-size: 14px;\n padding: 12px;\n\n &:hover {\n opacity: 0.7;\n }\n &:active,\n &:hover:focus {\n outline:none;\n }\n`;\n\nOptionElement.defaultProps = {\n theme: defaultTheme\n};\n\nexport default OptionElement;\n","import styled from 'styled-components';\n\nconst Options = styled.ul`\n margin: 2px 0 12px 0;\n padding: 0 6px;\n`;\n\nexport default Options;\n","import styled from 'styled-components';\n\nconst OptionsStepContainer = styled.div``;\n\nexport default OptionsStepContainer;\n","import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport Option from './Option';\nimport OptionElement from './OptionElement';\nimport Options from './Options';\nimport OptionsStepContainer from './OptionsStepContainer';\n\nclass OptionsStep extends Component {\n onOptionClick = ({ value }) => {\n const { triggerNextStep } = this.props;\n\n triggerNextStep({ value });\n };\n\n renderOption = option => {\n const { bubbleOptionStyle, step } = this.props;\n const { user } = step;\n const { value, label } = option;\n\n return (\n \n );\n };\n\n render() {\n const { step } = this.props;\n const { options } = step;\n\n return (\n \n \n {Object.keys(options).map(key => options[key]).map(this.renderOption)}\n \n \n );\n }\n}\n\nOptionsStep.propTypes = {\n bubbleOptionStyle: PropTypes.objectOf(PropTypes.any).isRequired,\n step: PropTypes.objectOf(PropTypes.any).isRequired,\n triggerNextStep: PropTypes.func.isRequired\n};\n\nexport default OptionsStep;\n","import styled from 'styled-components';\nimport { scale } from '../../common/animations';\nimport defaultTheme from '../../theme';\n\nconst Bubble = styled.div`\n animation: ${scale} 0.3s ease forwards;\n background: ${props => (props.user ? props.theme.userBubbleColor : props.theme.botBubbleColor)};\n border-radius: ${props => {\n const { isFirst, isLast, user } = props;\n\n if (!isFirst && !isLast) {\n return user ? '18px 0 0 18px' : '0 18px 18px 0px';\n }\n\n if (!isFirst && isLast) {\n return user ? '18px 0 18px 18px' : '0 18px 18px 18px';\n }\n\n return props.user ? '18px 18px 0 18px' : '18px 18px 18px 0';\n }};\n box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15);\n color: ${props => (props.user ? props.theme.userFontColor : props.theme.botFontColor)};\n display: inline-block;\n font-size: 14px;\n max-width: 50%;\n margin: ${props => {\n const { isFirst, showAvatar, user } = props;\n\n if (!isFirst && showAvatar) {\n return user ? '-8px 46px 10px 0' : '-8px 0 10px 46px';\n }\n\n if (!isFirst && !showAvatar) {\n return user ? '-8px 0px 10px 0' : '-8px 0 10px 0px';\n }\n\n return '0 0 10px 0';\n }};\n overflow: hidden;\n position: relative;\n padding: 12px;\n transform: scale(0);\n transform-origin: ${props => {\n const { isFirst, user } = props;\n\n if (isFirst) {\n return user ? 'bottom right' : 'bottom left';\n }\n\n return user ? 'top right' : 'top left';\n }};\n`;\n\nBubble.defaultProps = {\n theme: defaultTheme\n};\n\nexport default Bubble;\n","import styled from 'styled-components';\nimport { scale } from '../../common/animations';\n\nconst Image = styled.img`\n animation: ${scale} 0.3s ease forwards;\n border-radius: ${props => (props.user ? '50% 50% 50% 0' : '50% 50% 0 50%')};\n box-shadow: rgba(0, 0, 0, 0.15) 0px 1px 2px 0px;\n height: 40px;\n min-width: 40px;\n padding: 3px;\n transform: scale(0);\n transform-origin: ${props => (props.user ? 'bottom left' : 'bottom right')};\n width: 40;\n`;\n\nexport default Image;\n","import styled from 'styled-components';\n\nconst ImageContainer = styled.div`\n display: inline-block;\n order: ${props => (props.user ? '1' : '0')};\n padding: 6px;\n`;\n\nexport default ImageContainer;\n","import styled from 'styled-components';\n\nconst TextStepContainer = styled.div`\n align-items: flex-end;\n display: flex;\n justify-content: ${props => (props.user ? 'flex-end' : 'flex-start')};\n`;\n\nexport default TextStepContainer;\n","import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport Bubble from './Bubble';\nimport Image from './Image';\nimport ImageContainer from './ImageContainer';\nimport Loading from '../common/Loading';\nimport TextStepContainer from './TextStepContainer';\n\nclass TextStep extends Component {\n /* istanbul ignore next */\n state = {\n loading: true\n };\n\n componentDidMount() {\n const { step, speak, previousValue, triggerNextStep } = this.props;\n const { component, delay, waitAction } = step;\n const isComponentWatingUser = component && waitAction;\n\n setTimeout(() => {\n this.setState({ loading: false }, () => {\n if (!isComponentWatingUser && !step.rendered) {\n triggerNextStep();\n }\n speak(step, previousValue);\n });\n }, delay);\n }\n\n getMessage = () => {\n const { previousValue, step } = this.props;\n const { message } = step;\n\n return message ? message.replace(/{previousValue}/g, previousValue) : '';\n };\n\n renderMessage = () => {\n const { step, steps, previousStep, triggerNextStep } = this.props;\n const { component } = step;\n\n if (component) {\n return React.cloneElement(component, {\n step,\n steps,\n previousStep,\n triggerNextStep\n });\n }\n\n return this.getMessage();\n };\n\n render() {\n const {\n step,\n isFirst,\n isLast,\n avatarStyle,\n bubbleStyle,\n hideBotAvatar,\n hideUserAvatar\n } = this.props;\n const { loading } = this.state;\n const { avatar, user, botName } = step;\n\n const showAvatar = user ? !hideUserAvatar : !hideBotAvatar;\n\n const imageAltText = user ? \"Your avatar\" : `${botName}'s avatar`;\n\n return (\n \n \n {isFirst && showAvatar && (\n \n )}\n \n \n {loading ? : this.renderMessage()}\n \n \n );\n }\n}\n\nTextStep.propTypes = {\n avatarStyle: PropTypes.objectOf(PropTypes.any).isRequired,\n isFirst: PropTypes.bool.isRequired,\n isLast: PropTypes.bool.isRequired,\n bubbleStyle: PropTypes.objectOf(PropTypes.any).isRequired,\n hideBotAvatar: PropTypes.bool.isRequired,\n hideUserAvatar: PropTypes.bool.isRequired,\n previousStep: PropTypes.objectOf(PropTypes.any),\n previousValue: PropTypes.oneOfType([\n PropTypes.string,\n PropTypes.bool,\n PropTypes.number,\n PropTypes.object,\n PropTypes.array\n ]),\n speak: PropTypes.func,\n step: PropTypes.objectOf(PropTypes.any).isRequired,\n steps: PropTypes.objectOf(PropTypes.any),\n triggerNextStep: PropTypes.func.isRequired\n};\n\nTextStep.defaultProps = {\n previousStep: {},\n previousValue: '',\n speak: () => {},\n steps: {}\n};\n\nexport default TextStep;\n","export default [\n {\n key: 'id',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'user',\n types: ['boolean'],\n required: true\n },\n {\n key: 'hideExtraControl',\n types: ['boolean'],\n required: false\n },\n {\n key: 'trigger',\n types: ['string', 'number', 'function'],\n required: false\n },\n {\n key: 'validator',\n types: ['function'],\n required: false\n },\n {\n key: 'end',\n types: ['boolean'],\n required: false\n },\n {\n key: 'placeholder',\n types: ['string'],\n required: false\n },\n {\n key: 'inputAttributes',\n types: ['object'],\n required: false\n },\n {\n key: 'metadata',\n types: ['object'],\n required: false\n }\n];\n","export default [\n {\n key: 'id',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'message',\n types: ['string', 'function'],\n required: true\n },\n {\n key: 'avatar',\n types: ['string'],\n required: false\n },\n {\n key: 'trigger',\n types: ['string', 'number', 'function'],\n required: false\n },\n {\n key: 'delay',\n types: ['number'],\n required: false\n },\n {\n key: 'end',\n types: ['boolean'],\n required: false\n },\n {\n key: 'placeholder',\n types: ['string'],\n required: false\n },\n {\n key: 'hideInput',\n types: ['boolean'],\n required: false\n },\n {\n key: 'hideExtraControl',\n types: ['boolean'],\n required: false\n },\n {\n key: 'inputAttributes',\n types: ['object'],\n required: false\n },\n {\n key: 'metadata',\n types: ['object'],\n required: false\n }\n];\n","export default [\n {\n key: 'id',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'options',\n types: ['object'],\n required: true\n },\n {\n key: 'end',\n types: ['boolean'],\n required: false\n },\n {\n key: 'placeholder',\n types: ['string'],\n required: false\n },\n {\n key: 'hideInput',\n types: ['boolean'],\n required: false\n },\n {\n key: 'hideExtraControl',\n types: ['boolean'],\n required: false\n },\n {\n key: 'inputAttributes',\n types: ['object'],\n required: false\n },\n {\n key: 'metadata',\n types: ['object'],\n required: false\n }\n];\n","export default [\n {\n key: 'id',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'component',\n types: ['any'],\n required: true\n },\n {\n key: 'avatar',\n types: ['string'],\n required: false\n },\n {\n key: 'replace',\n types: ['boolean'],\n required: false\n },\n {\n key: 'waitAction',\n types: ['boolean'],\n required: false\n },\n {\n key: 'asMessage',\n types: ['boolean'],\n required: false\n },\n {\n key: 'trigger',\n types: ['string', 'number', 'function'],\n required: false\n },\n {\n key: 'delay',\n types: ['number'],\n required: false\n },\n {\n key: 'end',\n types: ['boolean'],\n required: false\n },\n {\n key: 'placeholder',\n types: ['string'],\n required: false\n },\n {\n key: 'hideInput',\n types: ['boolean'],\n required: false\n },\n {\n key: 'hideExtraControl',\n types: ['boolean'],\n required: false\n },\n {\n key: 'inputAttributes',\n types: ['object'],\n required: false\n },\n {\n key: 'metadata',\n types: ['object'],\n required: false\n }\n];\n","export default [\n {\n key: 'id',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'update',\n types: ['string', 'number'],\n required: true\n },\n {\n key: 'trigger',\n types: ['string', 'number', 'function'],\n required: true\n },\n {\n key: 'placeholder',\n types: ['string'],\n required: false\n },\n {\n key: 'inputAttributes',\n types: ['object'],\n required: false\n },\n {\n key: 'metadata',\n types: ['object'],\n required: false\n }\n];\n","import userSchema from './userSchema';\nimport textSchema from './textSchema';\nimport optionsSchema from './optionsSchema';\nimport customSchema from './customSchema';\nimport updateSchema from './updateSchema';\nimport { stringify } from 'flatted/cjs';\n\nconst schema = {\n parse(step) {\n let parser = [];\n\n if (step.user) {\n parser = userSchema;\n } else if (step.message) {\n parser = textSchema;\n } else if (step.options) {\n parser = optionsSchema;\n } else if (step.component) {\n parser = customSchema;\n } else if (step.update) {\n parser = updateSchema;\n } else {\n throw new Error(`The step ${stringify(step)} is invalid`);\n }\n\n for (let i = 0, len = parser.length; i < len; i += 1) {\n const { key, types, required } = parser[i];\n\n if (!step[key] && required) {\n throw new Error(`Key '${key}' is required in step ${stringify(step)}`);\n } else if (step[key]) {\n if (types[0] !== 'any' && types.indexOf(typeof step[key]) < 0) {\n throw new Error(\n `The type of '${key}' value must be ${types.join(' or ')} instead of ${typeof step[\n key\n ]}`\n );\n }\n }\n }\n\n const keys = parser.map(p => p.key);\n\n for (const key in step) {\n if (keys.indexOf(key) < 0) {\n console.error(`Invalid key '${key}' in step '${step.id}'`);\n delete step[key];\n }\n }\n\n return step;\n },\n\n checkInvalidIds(steps) {\n for (const key in steps) {\n const step = steps[key];\n const triggerId = steps[key].trigger;\n\n if (typeof triggerId !== 'function') {\n if (step.options) {\n const triggers = step.options.filter(option => typeof option.trigger !== 'function');\n const optionsTriggerIds = triggers.map(option => option.trigger);\n\n for (let i = 0, len = optionsTriggerIds.length; i < len; i += 1) {\n const optionTriggerId = optionsTriggerIds[i];\n if (optionTriggerId && !steps[optionTriggerId]) {\n throw new Error(\n `The id '${optionTriggerId}' triggered by option ${i + 1} in step '${\n steps[key].id\n }' does not exist`\n );\n }\n }\n } else if (triggerId && !steps[triggerId]) {\n throw new Error(\n `The id '${triggerId}' triggered by step '${steps[key].id}' does not exist`\n );\n }\n }\n }\n }\n};\n\nexport default schema;\n","import { stringify, parse } from 'flatted/cjs'\n\n/* istanbul ignore next */\nconst getData = (params, callback) => {\n const { cacheName, cache, firstStep, steps } = params;\n const currentStep = firstStep;\n const renderedSteps = [steps[currentStep.id]];\n const previousSteps = [steps[currentStep.id]];\n const previousStep = {};\n const unParsedCache = localStorage.getItem(cacheName);\n\n if (cache && unParsedCache) {\n try {\n const data = parse(unParsedCache);\n const lastStep = data.renderedSteps[data.renderedSteps.length - 1];\n\n if (lastStep && lastStep.end) {\n localStorage.removeItem(cacheName);\n } else {\n for (let i = 0, len = data.renderedSteps.length; i < len; i += 1) {\n const renderedStep = data.renderedSteps[i];\n // remove delay of cached rendered steps\n data.renderedSteps[i].delay = 0;\n // flag used to avoid call triggerNextStep in cached rendered steps\n data.renderedSteps[i].rendered = true;\n\n // an error is thrown when render a component from localStorage.\n // So it's necessary reassing the component\n if (renderedStep.component) {\n const { id } = renderedStep;\n data.renderedSteps[i].component = steps[id].component;\n }\n }\n\n const { trigger, end, options } = data.currentStep;\n const { id } = data.currentStep;\n\n if (options) {\n delete data.currentStep.rendered;\n }\n\n // add trigger function to current step\n if (!trigger && !end) {\n if (options) {\n for (let i = 0; i < options.length; i += 1) {\n data.currentStep.options[i].trigger = steps[id].options[i].trigger;\n }\n } else {\n data.currentStep.trigger = steps[id].trigger;\n }\n }\n\n // execute callback function to enable input if last step is\n // waiting user type\n if (data.currentStep.user) {\n callback();\n }\n\n return data;\n }\n } catch (error) {\n console.info(`Unable to parse cache named:${cacheName}. \\nThe cache where probably created with an older version of react-simple-chatbot.\\n`, error);\n }\n }\n\n return {\n currentStep,\n previousStep,\n previousSteps,\n renderedSteps\n };\n};\n\n/* istanbul ignore next */\nconst setData = (cacheName, cachedData) => {\n const data = parse(stringify(cachedData));\n // clean components\n for (const key in data) {\n for (let i = 0, len = data[key].length; i < len; i += 1) {\n if (data[key][i].component) {\n data[key][i].component = data[key][i].id;\n }\n }\n }\n\n localStorage.setItem(cacheName, stringify(data));\n};\n\nexport { getData, setData };\n","import styled from 'styled-components';\nimport defaultTheme from '../theme';\n\nconst ChatBotContainer = styled.div`\n background: ${({ theme }) => theme.background};\n border-radius: 10px;\n box-shadow: 0 12px 24px 0 rgba(0, 0, 0, 0.15);\n font-family: ${({ theme }) => theme.fontFamily};\n overflow: hidden;\n position: ${({ floating }) => (floating ? 'fixed' : 'relative')};\n bottom: ${({ floating, floatingStyle }) =>\n floating ? floatingStyle.bottom || '32px' : 'initial'};\n top: ${({ floating, floatingStyle }) => (floating ? floatingStyle.top || 'initial' : 'initial')};\n right: ${({ floating, floatingStyle }) => (floating ? floatingStyle.right || '32px' : 'initial')};\n left: ${({ floating, floatingStyle }) =>\n floating ? floatingStyle.left || 'initial' : 'initial'};\n width: ${({ width }) => width};\n height: ${({ height }) => height};\n z-index: 999;\n transform: ${({ opened }) => (opened ? 'scale(1)' : 'scale(0)')};\n transform-origin: ${({ floatingStyle }) => floatingStyle.transformOrigin || 'bottom right'};\n transition: transform 0.3s ease;\n\n @media screen and (max-width: 568px) {\n border-radius: ${({ floating }) => (floating ? '0' : '')};\n bottom: 0 !important;\n left: initial !important;\n height: 100%;\n right: 0 !important;\n top: initial !important;\n width: 100%;\n }\n`;\n\nChatBotContainer.defaultProps = {\n theme: defaultTheme\n};\n\nexport default ChatBotContainer;\n","import styled from 'styled-components';\n\nconst Content = styled.div`\n height: calc(${props => props.height} - ${props => (props.hideInput ? '56px' : '112px')});\n overflow-y: scroll;\n margin-top: 2px;\n padding-top: 6px;\n\n @media screen and (max-width: 568px) {\n height: ${props => (props.floating ? 'calc(100% - 112px)' : '')};\n }\n`;\n\nexport default Content;\n","import styled from 'styled-components';\nimport defaultTheme from '../theme';\n\nconst Header = styled.div`\n align-items: center;\n background: ${({ theme }) => theme.headerBgColor};\n color: ${({ theme }) => theme.headerFontColor};\n display: flex;\n fill: ${({ theme }) => theme.headerFontColor};\n height: 56px;\n justify-content: space-between;\n padding: 0 10px;\n`;\n\nHeader.defaultProps = {\n theme: defaultTheme\n};\n\nexport default Header;\n","import styled from 'styled-components';\nimport defaultTheme from '../theme';\n\nconst HeaderTitle = styled.h2`\n margin: 0;\n font-size: ${({ theme }) => theme.headerFontSize};\n`;\n\nHeaderTitle.defaultProps = {\n theme: defaultTheme\n};\n\nexport default HeaderTitle;\n","import styled from 'styled-components';\n\nconst HeaderIcon = styled.a`\n cursor: pointer;\n`;\n\nexport default HeaderIcon;\n","import styled from 'styled-components';\n\nconst FloatButton = styled.a`\n align-items: center;\n cursor: pointer;\n background: ${({ theme }) => theme.headerBgColor};\n bottom: 32px;\n border-radius: 100%;\n box-shadow: 0 12px 24px 0 rgba(0, 0, 0, 0.15);\n display: flex;\n fill: ${({ theme }) => theme.headerFontColor};\n height: 56px;\n justify-content: center;\n position: fixed;\n right: 32px;\n transform: ${props => (props.opened ? 'scale(0)' : 'scale(1)')};\n transition: transform 0.3s ease;\n width: 56px;\n z-index: 999;\n`;\n\nFloatButton.defaultProps = {\n theme: {\n headerBgColor: '#6e48aa',\n headerFontColor: '#fff'\n }\n};\n\nexport default FloatButton;\n","import styled from 'styled-components';\n\nconst FloatingIcon = styled.img`\n height: 24px;\n width: 24px;\n`;\n\nexport default FloatingIcon;\n","import styled from 'styled-components';\n\nconst Footer = styled.div`\n position: relative;\n`;\n\nexport default Footer;\n","import { invalidInput } from '../common/animations';\nimport styled, { css } from 'styled-components';\n\nconst Input = styled.input`\n animation: ${props =>\n props.invalid\n ? css`\n ${invalidInput} .2s ease\n `\n : ''};\n border: 0;\n border-radius: 0;\n border-bottom-left-radius: 10px;\n border-bottom-right-radius: 10px;\n border-top: ${props => (props.invalid ? '0' : '1px solid #eee')};\n box-shadow: ${props => (props.invalid ? 'inset 0 0 2px #E53935' : 'none')};\n box-sizing: border-box;\n color: ${props => (props.invalid ? '#E53935' : '')};\n font-size: 16px;\n opacity: ${props => (props.disabled && !props.invalid ? '.5' : '1')};\n outline: none;\n padding: ${props => (props.hasButton ? '16px 52px 16px 10px' : '16px 10px')};\n width: 100%;\n -webkit-appearance: none;\n\n &:disabled {\n background: #fff;\n }\n\n @media screen and (max-width: 568px) {\n border-bottom-left-radius: ${props => (props.floating ? '0' : '10px')};\n border-bottom-right-radius: ${props => (props.floating ? '0' : '10px')};\n }\n`;\n\nexport default Input;\n","import styled, { css } from 'styled-components';\nimport defaultTheme from '../theme';\nimport { pulse } from '../common/animations';\n\nconst fillFunc = props => {\n const { speaking, invalid, theme } = props;\n\n if (speaking) {\n return theme.headerBgColor;\n }\n return invalid ? '#E53935' : '#4a4a4a';\n};\n\nconst SubmitButton = styled.button`\n background-color: transparent;\n border: 0;\n border-bottom-right-radius: 10px;\n box-shadow: none;\n cursor: ${props => (props.disabled ? 'default' : 'pointer')};\n fill: ${fillFunc};\n opacity: ${props => (props.disabled && !props.invalid ? '.5' : '1')};\n outline: none;\n padding: 14px 16px 12px 16px;\n &:before {\n content: '';\n position: absolute;\n width: 23px;\n height: 23px;\n border-radius: 50%;\n animation: ${({ theme, speaking }) =>\n speaking\n ? css`\n ${pulse(theme.headerBgColor)} 2s ease infinite\n `\n : ''};\n }\n &:not(:disabled):hover {\n opacity: 0.7;\n }\n`;\n\nSubmitButton.defaultProps = {\n theme: defaultTheme\n};\n\nexport default SubmitButton;\n","let instance = null;\n\nconst noop = () => {};\n\nexport default class Recognition {\n static isSupported() {\n return 'webkitSpeechRecognition' in window;\n }\n\n /**\n * Creates an instance of Recognition.\n * @param {function} [onChange] callback on change\n * @param {function} [onEnd] callback on and\n * @param {function} [onStop] callback on stop\n * @param {string} [lang='en'] recognition lang\n * @memberof Recognition\n * @constructor\n */\n constructor(onChange = noop, onEnd = noop, onStop = noop, lang = 'en') {\n if (!instance) {\n instance = this;\n }\n this.state = {\n inputValue: '',\n lang,\n onChange,\n onEnd,\n onStop\n };\n\n this.onResult = this.onResult.bind(this);\n this.onEnd = this.onEnd.bind(this);\n\n this.setup();\n\n return instance;\n }\n\n /**\n * Handler for recognition change event\n * @param {string} interimTranscript\n * @memberof Recognition\n * @private\n */\n onChange(interimTranscript) {\n const { onChange } = this.state;\n this.setState({\n inputValue: interimTranscript\n });\n onChange(interimTranscript);\n }\n\n /**\n * Handler for recognition change event when its final\n * @param {string} finalTranscript\n * @memberof Recognition\n * @private\n */\n onFinal(finalTranscript) {\n this.setState({\n inputValue: finalTranscript\n });\n this.recognition.stop();\n }\n\n /**\n * Handler for recognition end event\n * @memberof Recognition\n * @private\n */\n onEnd() {\n const { onStop, onEnd, force } = this.state;\n this.setState({ speaking: false });\n if (force) {\n onStop();\n } else {\n onEnd();\n }\n }\n\n /**\n * Handler for recognition result event\n * @memberof Recognition\n * @private\n */\n onResult(event) {\n let interimTranscript = '';\n let finalTranscript = '';\n\n for (let i = event.resultIndex; i < event.results.length; i += 1) {\n if (event.results[i].isFinal) {\n finalTranscript += event.results[i][0].transcript;\n this.onFinal(finalTranscript);\n } else {\n interimTranscript += event.results[i][0].transcript;\n this.onChange(interimTranscript);\n }\n }\n }\n\n /**\n * method for updating the instance state\n * @param {object} nextState\n * @memberof Recognition\n * @private\n */\n setState(nextState) {\n this.state = Object.assign({}, this.state, nextState);\n }\n\n /**\n * setup the browser recognition\n * @returns {Recognition}\n * @memberof Recognition\n * @public\n */\n setup() {\n if (!Recognition.isSupported()) {\n return this;\n }\n\n const { webkitSpeechRecognition } = window;\n\n this.recognition = new webkitSpeechRecognition();\n this.recognition.continuous = true;\n this.recognition.interimResults = true;\n this.recognition.lang = this.state.lang;\n this.recognition.onresult = this.onResult;\n this.recognition.onend = this.onEnd;\n return this;\n }\n\n /**\n * change the recognition lang and resetup\n * @param {string} lang the new lang\n * @returns {Recognition}\n * @memberof Recognition\n * @public\n */\n setLang(lang) {\n this.setState({ lang });\n this.setup();\n return this;\n }\n\n /**\n * toggle the recognition\n * @returns {Recognition}\n * @memberof Recognition\n * @public\n */\n speak() {\n if (!Recognition.isSupported()) {\n return this;\n }\n const { speaking } = this.state;\n if (!speaking) {\n this.recognition.start();\n this.setState({\n speaking: true,\n inputValue: ''\n });\n } else {\n this.setState({\n force: true\n });\n this.recognition.stop();\n }\n return this;\n }\n}\n","import React from 'react';\n\nconst ChatIcon = () => (\n \n \n \n \n);\n\nexport default ChatIcon;\n","import React from 'react';\n\nconst CloseIcon = () => (\n \n \n \n \n);\n\nexport default CloseIcon;\n","import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst SubmitIcon = ({ size }) => (\n \n \n \n \n \n \n \n);\n\nSubmitIcon.propTypes = {\n size: PropTypes.number\n};\n\nSubmitIcon.defaultProps = {\n size: 20\n};\n\nexport default SubmitIcon;\n","import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst MicIcon = ({ size }) => (\n \n \n \n \n \n \n);\n\nMicIcon.propTypes = {\n size: PropTypes.number\n};\n\nMicIcon.defaultProps = {\n size: 20\n};\n\nexport default MicIcon;\n","export const isMobile = () => /iphone|ipod|android|ie|blackberry|fennec/i.test(navigator.userAgent);\n\nexport const isString = value => typeof value === 'string';\n","import { isString } from './utils';\n\nexport const getSpeakText = step => {\n const { message, metadata = {} } = step;\n if (isString(metadata.speak)) {\n return metadata.speak;\n }\n if (isString(message)) {\n return message;\n }\n return '';\n};\n\nexport const speakFn = speechSynthesisOptions => (step, previousValue) => {\n const { lang, voice, enable } = speechSynthesisOptions;\n const { user } = step;\n\n if (!window.SpeechSynthesisUtterance || !window.speechSynthesis) {\n return;\n }\n if (user) {\n return;\n }\n if (!enable) {\n return;\n }\n const text = getSpeakText(step);\n const msg = new window.SpeechSynthesisUtterance();\n msg.text = text.replace(/{previousValue}/g, previousValue);\n msg.lang = lang;\n msg.voice = voice;\n window.speechSynthesis.speak(msg);\n};\n","import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport Random from 'random-id';\nimport { CustomStep, OptionsStep, TextStep } from './steps_components';\nimport schema from './schemas/schema';\nimport * as storage from './storage';\nimport {\n ChatBotContainer,\n Content,\n Header,\n HeaderTitle,\n HeaderIcon,\n FloatButton,\n FloatingIcon,\n Footer,\n Input,\n SubmitButton\n} from './components';\nimport Recognition from './recognition';\nimport { ChatIcon, CloseIcon, SubmitIcon, MicIcon } from './icons';\nimport { isMobile } from './utils';\nimport { speakFn } from './speechSynthesis';\n\nclass ChatBot extends Component {\n /* istanbul ignore next */\n constructor(props) {\n super(props);\n\n this.content = null;\n this.input = null;\n\n this.supportsScrollBehavior = false;\n\n this.setContentRef = element => {\n this.content = element;\n };\n\n this.setInputRef = element => {\n this.input = element;\n };\n\n this.state = {\n renderedSteps: [],\n previousSteps: [],\n currentStep: {},\n previousStep: {},\n steps: {},\n disabled: true,\n opened: props.opened || !props.floating,\n inputValue: '',\n inputInvalid: false,\n speaking: false,\n recognitionEnable: props.recognitionEnable && Recognition.isSupported(),\n defaultUserSettings: {}\n };\n\n this.speak = speakFn(props.speechSynthesis);\n }\n\n componentDidMount() {\n const { steps } = this.props;\n const {\n botDelay,\n botAvatar,\n botName,\n cache,\n cacheName,\n customDelay,\n enableMobileAutoFocus,\n userAvatar,\n userDelay\n } = this.props;\n const chatSteps = {};\n\n const defaultBotSettings = { delay: botDelay, avatar: botAvatar, botName };\n const defaultUserSettings = {\n delay: userDelay,\n avatar: userAvatar,\n hideInput: false,\n hideExtraControl: false\n };\n const defaultCustomSettings = { delay: customDelay };\n\n for (let i = 0, len = steps.length; i < len; i += 1) {\n const step = steps[i];\n let settings = {};\n\n if (step.user) {\n settings = defaultUserSettings;\n } else if (step.message || step.asMessage) {\n settings = defaultBotSettings;\n } else if (step.component) {\n settings = defaultCustomSettings;\n }\n\n chatSteps[step.id] = Object.assign({}, settings, schema.parse(step));\n }\n\n schema.checkInvalidIds(chatSteps);\n\n const firstStep = steps[0];\n\n if (firstStep.message) {\n const { message } = firstStep;\n firstStep.message = typeof message === 'function' ? message() : message;\n chatSteps[firstStep.id].message = firstStep.message;\n }\n\n const { recognitionEnable } = this.state;\n const { recognitionLang } = this.props;\n\n if (recognitionEnable) {\n this.recognition = new Recognition(\n this.onRecognitionChange,\n this.onRecognitionEnd,\n this.onRecognitionStop,\n recognitionLang\n );\n }\n\n this.supportsScrollBehavior = 'scrollBehavior' in document.documentElement.style;\n\n if (this.content) {\n this.content.addEventListener('DOMNodeInserted', this.onNodeInserted);\n window.addEventListener('resize', this.onResize);\n }\n\n const { currentStep, previousStep, previousSteps, renderedSteps } = storage.getData(\n {\n cacheName,\n cache,\n firstStep,\n steps: chatSteps\n },\n () => {\n // focus input if last step cached is a user step\n this.setState({ disabled: false }, () => {\n if (enableMobileAutoFocus || !isMobile()) {\n if (this.input) {\n this.input.focus();\n }\n }\n });\n }\n );\n\n this.setState({\n currentStep,\n defaultUserSettings,\n previousStep,\n previousSteps,\n renderedSteps,\n steps: chatSteps\n });\n }\n\n static getDerivedStateFromProps(props, state) {\n const { opened, toggleFloating } = props;\n if (toggleFloating !== undefined && opened !== undefined && opened !== state.opened) {\n return {\n ...state,\n opened\n };\n }\n return state;\n }\n\n componentWillUnmount() {\n if (this.content) {\n this.content.removeEventListener('DOMNodeInserted', this.onNodeInserted);\n window.removeEventListener('resize', this.onResize);\n }\n }\n\n onNodeInserted = event => {\n const { currentTarget: target } = event;\n const { enableSmoothScroll } = this.props;\n\n if (enableSmoothScroll && this.supportsScrollBehavior) {\n target.scroll({\n top: target.scrollHeight,\n left: 0,\n behavior: 'smooth'\n });\n } else {\n target.scrollTop = target.scrollHeight;\n }\n };\n\n onResize = () => {\n this.content.scrollTop = this.content.scrollHeight;\n };\n\n onRecognitionChange = value => {\n this.setState({ inputValue: value });\n };\n\n onRecognitionEnd = () => {\n this.setState({ speaking: false });\n this.handleSubmitButton();\n };\n\n onRecognitionStop = () => {\n this.setState({ speaking: false });\n };\n\n onValueChange = event => {\n this.setState({ inputValue: event.target.value });\n };\n\n getTriggeredStep = (trigger, value) => {\n const steps = this.generateRenderedStepsById();\n return typeof trigger === 'function' ? trigger({ value, steps }) : trigger;\n };\n\n getStepMessage = message => {\n const { previousSteps } = this.state;\n const lastStepIndex = previousSteps.length > 0 ? previousSteps.length - 1 : 0;\n const steps = this.generateRenderedStepsById();\n const previousValue = previousSteps[lastStepIndex].value;\n return typeof message === 'function' ? message({ previousValue, steps }) : message;\n };\n\n generateRenderedStepsById = () => {\n const { previousSteps } = this.state;\n const steps = {};\n\n for (let i = 0, len = previousSteps.length; i < len; i += 1) {\n const { id, message, value, metadata } = previousSteps[i];\n\n steps[id] = {\n id,\n message,\n value,\n metadata\n };\n }\n\n return steps;\n };\n\n triggerNextStep = data => {\n const { enableMobileAutoFocus } = this.props;\n const { defaultUserSettings, previousSteps, renderedSteps, steps } = this.state;\n\n let { currentStep, previousStep } = this.state;\n const isEnd = currentStep.end;\n\n if (data && data.value) {\n currentStep.value = data.value;\n }\n if (data && data.hideInput) {\n currentStep.hideInput = data.hideInput;\n }\n if (data && data.hideExtraControl) {\n currentStep.hideExtraControl = data.hideExtraControl;\n }\n if (data && data.trigger) {\n currentStep.trigger = this.getTriggeredStep(data.trigger, data.value);\n }\n\n if (isEnd) {\n this.handleEnd();\n } else if (currentStep.options && data) {\n const option = currentStep.options.filter(o => o.value === data.value)[0];\n const trigger = this.getTriggeredStep(option.trigger, currentStep.value);\n delete currentStep.options;\n\n // replace choose option for user message\n currentStep = Object.assign({}, currentStep, option, defaultUserSettings, {\n user: true,\n message: option.label,\n trigger\n });\n\n renderedSteps.pop();\n previousSteps.pop();\n renderedSteps.push(currentStep);\n previousSteps.push(currentStep);\n\n this.setState({\n currentStep,\n renderedSteps,\n previousSteps\n });\n } else if (currentStep.trigger) {\n if (currentStep.replace) {\n renderedSteps.pop();\n }\n\n const trigger = this.getTriggeredStep(currentStep.trigger, currentStep.value);\n let nextStep = Object.assign({}, steps[trigger]);\n\n if (nextStep.message) {\n nextStep.message = this.getStepMessage(nextStep.message);\n } else if (nextStep.update) {\n const updateStep = nextStep;\n nextStep = Object.assign({}, steps[updateStep.update]);\n\n if (nextStep.options) {\n for (let i = 0, len = nextStep.options.length; i < len; i += 1) {\n nextStep.options[i].trigger = updateStep.trigger;\n }\n } else {\n nextStep.trigger = updateStep.trigger;\n }\n }\n\n nextStep.key = Random(24);\n\n previousStep = currentStep;\n currentStep = nextStep;\n\n this.setState({ renderedSteps, currentStep, previousStep }, () => {\n if (nextStep.user) {\n this.setState({ disabled: false }, () => {\n if (enableMobileAutoFocus || !isMobile()) {\n if (this.input) {\n this.input.focus();\n }\n }\n });\n } else {\n renderedSteps.push(nextStep);\n previousSteps.push(nextStep);\n\n this.setState({ renderedSteps, previousSteps });\n }\n });\n }\n\n const { cache, cacheName } = this.props;\n if (cache) {\n setTimeout(() => {\n storage.setData(cacheName, {\n currentStep,\n previousStep,\n previousSteps,\n renderedSteps\n });\n }, 300);\n }\n };\n\n handleEnd = () => {\n const { handleEnd } = this.props;\n\n if (handleEnd) {\n const { previousSteps } = this.state;\n\n const renderedSteps = previousSteps.map(step => {\n const { id, message, value, metadata } = step;\n\n return {\n id,\n message,\n value,\n metadata\n };\n });\n\n const steps = [];\n\n for (let i = 0, len = previousSteps.length; i < len; i += 1) {\n const { id, message, value, metadata } = previousSteps[i];\n\n steps[id] = {\n id,\n message,\n value,\n metadata\n };\n }\n\n const values = previousSteps.filter(step => step.value).map(step => step.value);\n\n handleEnd({ renderedSteps, steps, values });\n }\n };\n\n isInputValueEmpty = () => {\n const { inputValue } = this.state;\n return !inputValue || inputValue.length === 0;\n };\n\n isLastPosition = step => {\n const { renderedSteps } = this.state;\n const { length } = renderedSteps;\n const stepIndex = renderedSteps.map(s => s.key).indexOf(step.key);\n\n if (length <= 1 || stepIndex + 1 === length) {\n return true;\n }\n\n const nextStep = renderedSteps[stepIndex + 1];\n const hasMessage = nextStep.message || nextStep.asMessage;\n\n if (!hasMessage) {\n return true;\n }\n\n const isLast = step.user !== nextStep.user;\n return isLast;\n };\n\n isFirstPosition = step => {\n const { renderedSteps } = this.state;\n const stepIndex = renderedSteps.map(s => s.key).indexOf(step.key);\n\n if (stepIndex === 0) {\n return true;\n }\n\n const lastStep = renderedSteps[stepIndex - 1];\n const hasMessage = lastStep.message || lastStep.asMessage;\n\n if (!hasMessage) {\n return true;\n }\n\n const isFirst = step.user !== lastStep.user;\n return isFirst;\n };\n\n handleKeyPress = event => {\n if (event.key === 'Enter') {\n this.submitUserMessage();\n }\n };\n\n handleSubmitButton = () => {\n const { speaking, recognitionEnable } = this.state;\n\n if ((this.isInputValueEmpty() || speaking) && recognitionEnable) {\n this.recognition.speak();\n if (!speaking) {\n this.setState({ speaking: true });\n }\n return;\n }\n\n this.submitUserMessage();\n };\n\n submitUserMessage = () => {\n const { defaultUserSettings, inputValue, previousSteps, renderedSteps } = this.state;\n let { currentStep } = this.state;\n\n const isInvalid = currentStep.validator && this.checkInvalidInput();\n\n if (!isInvalid) {\n const step = {\n message: inputValue,\n value: inputValue\n };\n\n currentStep = Object.assign({}, defaultUserSettings, currentStep, step);\n\n renderedSteps.push(currentStep);\n previousSteps.push(currentStep);\n\n this.setState(\n {\n currentStep,\n renderedSteps,\n previousSteps,\n disabled: true,\n inputValue: ''\n },\n () => {\n if (this.input) {\n this.input.blur();\n }\n }\n );\n }\n };\n\n checkInvalidInput = () => {\n const { enableMobileAutoFocus } = this.props;\n const { currentStep, inputValue } = this.state;\n const result = currentStep.validator(inputValue);\n const value = inputValue;\n\n if (typeof result !== 'boolean' || !result) {\n this.setState(\n {\n inputValue: result.toString(),\n inputInvalid: true,\n disabled: true\n },\n () => {\n setTimeout(() => {\n this.setState(\n {\n inputValue: value,\n inputInvalid: false,\n disabled: false\n },\n () => {\n if (enableMobileAutoFocus || !isMobile()) {\n if (this.input) {\n this.input.focus();\n }\n }\n }\n );\n }, 2000);\n }\n );\n\n return true;\n }\n\n return false;\n };\n\n toggleChatBot = opened => {\n const { toggleFloating } = this.props;\n\n if (toggleFloating) {\n toggleFloating({ opened });\n } else {\n this.setState({ opened });\n }\n };\n\n renderStep = (step, index) => {\n const { renderedSteps } = this.state;\n const {\n avatarStyle,\n bubbleStyle,\n bubbleOptionStyle,\n customStyle,\n hideBotAvatar,\n hideUserAvatar,\n speechSynthesis\n } = this.props;\n const { options, component, asMessage } = step;\n const steps = this.generateRenderedStepsById();\n const previousStep = index > 0 ? renderedSteps[index - 1] : {};\n\n if (component && !asMessage) {\n return (\n \n );\n }\n\n if (options) {\n return (\n \n );\n }\n\n return (\n \n );\n };\n\n render() {\n const {\n currentStep,\n disabled,\n inputInvalid,\n inputValue,\n opened,\n renderedSteps,\n speaking,\n recognitionEnable\n } = this.state;\n const {\n className,\n contentStyle,\n extraControl,\n controlStyle,\n floating,\n floatingIcon,\n floatingStyle,\n footerStyle,\n headerComponent,\n headerTitle,\n hideHeader,\n hideSubmitButton,\n inputStyle,\n placeholder,\n inputAttributes,\n recognitionPlaceholder,\n style,\n submitButtonStyle,\n width,\n height\n } = this.props;\n\n const header = headerComponent || (\n
\n {headerTitle}\n {floating && (\n this.toggleChatBot(false)}>\n \n \n )}\n
\n );\n\n let customControl;\n if (extraControl !== undefined) {\n customControl = React.cloneElement(extraControl, {\n disabled,\n speaking,\n invalid: inputInvalid\n });\n }\n\n const icon =\n (this.isInputValueEmpty() || speaking) && recognitionEnable ? : ;\n\n const inputPlaceholder = speaking\n ? recognitionPlaceholder\n : currentStep.placeholder || placeholder;\n\n const inputAttributesOverride = currentStep.inputAttributes || inputAttributes;\n\n return (\n
\n {floating && (\n this.toggleChatBot(true)}\n >\n {typeof floatingIcon === 'string' ? : floatingIcon}\n \n )}\n \n {!hideHeader && header}\n \n {renderedSteps.map(this.renderStep)}\n \n
\n {!currentStep.hideInput && (\n \n )}\n
\n {!currentStep.hideInput && !currentStep.hideExtraControl && customControl}\n {!currentStep.hideInput && !hideSubmitButton && (\n \n {icon}\n \n )}\n
\n
\n \n
\n );\n }\n}\n\nChatBot.propTypes = {\n avatarStyle: PropTypes.objectOf(PropTypes.any),\n botAvatar: PropTypes.string,\n botName: PropTypes.string,\n botDelay: PropTypes.number,\n bubbleOptionStyle: PropTypes.objectOf(PropTypes.any),\n bubbleStyle: PropTypes.objectOf(PropTypes.any),\n cache: PropTypes.bool,\n cacheName: PropTypes.string,\n className: PropTypes.string,\n contentStyle: PropTypes.objectOf(PropTypes.any),\n customDelay: PropTypes.number,\n customStyle: PropTypes.objectOf(PropTypes.any),\n controlStyle: PropTypes.objectOf(PropTypes.any),\n enableMobileAutoFocus: PropTypes.bool,\n enableSmoothScroll: PropTypes.bool,\n extraControl: PropTypes.objectOf(PropTypes.element),\n floating: PropTypes.bool,\n floatingIcon: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),\n floatingStyle: PropTypes.objectOf(PropTypes.any),\n footerStyle: PropTypes.objectOf(PropTypes.any),\n handleEnd: PropTypes.func,\n headerComponent: PropTypes.element,\n headerTitle: PropTypes.string,\n height: PropTypes.string,\n hideBotAvatar: PropTypes.bool,\n hideHeader: PropTypes.bool,\n hideSubmitButton: PropTypes.bool,\n hideUserAvatar: PropTypes.bool,\n inputAttributes: PropTypes.objectOf(PropTypes.any),\n inputStyle: PropTypes.objectOf(PropTypes.any),\n opened: PropTypes.bool,\n toggleFloating: PropTypes.func,\n placeholder: PropTypes.string,\n recognitionEnable: PropTypes.bool,\n recognitionLang: PropTypes.string,\n recognitionPlaceholder: PropTypes.string,\n speechSynthesis: PropTypes.shape({\n enable: PropTypes.bool,\n lang: PropTypes.string,\n voice:\n typeof window !== 'undefined'\n ? PropTypes.instanceOf(window.SpeechSynthesisVoice)\n : PropTypes.any\n }),\n steps: PropTypes.arrayOf(PropTypes.object).isRequired,\n style: PropTypes.objectOf(PropTypes.any),\n submitButtonStyle: PropTypes.objectOf(PropTypes.any),\n userAvatar: PropTypes.string,\n userDelay: PropTypes.number,\n width: PropTypes.string\n};\n\nChatBot.defaultProps = {\n avatarStyle: {},\n botDelay: 1000,\n botName: 'The bot',\n bubbleOptionStyle: {},\n bubbleStyle: {},\n cache: false,\n cacheName: 'rsc_cache',\n className: '',\n contentStyle: {},\n customStyle: {},\n controlStyle: { position: 'absolute', right: '0', top: '0' },\n customDelay: 1000,\n enableMobileAutoFocus: false,\n enableSmoothScroll: false,\n extraControl: undefined,\n floating: false,\n floatingIcon: ,\n floatingStyle: {},\n footerStyle: {},\n handleEnd: undefined,\n headerComponent: undefined,\n headerTitle: 'Chat',\n height: '520px',\n hideBotAvatar: false,\n hideHeader: false,\n hideSubmitButton: false,\n hideUserAvatar: false,\n inputStyle: {},\n opened: undefined,\n placeholder: 'Type the message ...',\n inputAttributes: {},\n recognitionEnable: false,\n recognitionLang: 'en',\n recognitionPlaceholder: 'Listening ...',\n speechSynthesis: {\n enable: false,\n lang: 'en',\n voice: null\n },\n style: {},\n submitButtonStyle: {},\n toggleFloating: undefined,\n userDelay: 1000,\n width: '350px',\n botAvatar:\n \"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\",\n userAvatar:\n \"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\"\n};\n\nexport default ChatBot;\n","import ChatBot from './ChatBot';\nimport Loading from './steps_components/common/Loading';\n\nexport default ChatBot;\nexport { Loading };\n"],"sourceRoot":""} \ No newline at end of file diff --git a/example/components/Example.jsx b/example/components/Example.jsx index 5cd87354..11b3f44b 100644 --- a/example/components/Example.jsx +++ b/example/components/Example.jsx @@ -14,19 +14,30 @@ const otherFontTheme = { userFontColor: '#4a4a4a' }; +const Summary = ({ steps }) => ( +
+ {steps.name.value} likes {steps.fruit.value} +
+); + const steps = [ + { id: '1', message: 'Hello! What is your name?', trigger: 'name' }, + { id: 'name', user: true, trigger: '3' }, + { id: '3', message: 'Hi {previousValue}! Which fruit do you like?', trigger: 'fruit' }, { - id: '1', - message: 'Hello World', - end: true - } + id: 'fruit', + options: [ + { label: 'Apple', trigger: 'summary' }, + { label: 'Banana', trigger: 'summary' } + ] + }, + { id: 'summary', component: , asMessage: true, trigger: 'end' }, + { id: 'end', message: 'Thanks!', end: true } ]; const ThemedExample = () => ( - - - + ); diff --git a/example/main.jsx b/example/main.jsx index cb81aa5a..29ed01e2 100644 --- a/example/main.jsx +++ b/example/main.jsx @@ -1,5 +1,9 @@ -import React from 'react'; -import { render } from 'react-dom'; +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; import Example from './components/Example'; -render(, document.getElementById('root')); +createRoot(document.getElementById('root')).render( + + + +); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 00000000..8f81d9fe --- /dev/null +++ b/index.d.ts @@ -0,0 +1,152 @@ +import * as React from 'react'; + +export type StepId = string | number; + +export interface RenderedStep { + id: StepId; + message?: string; + value?: any; + metadata?: Record; +} + +export type RenderedSteps = Record; + +export type Trigger = StepId | ((args: { value: any; steps: RenderedSteps }) => StepId); + +export type Message = string | ((args: { previousValue: any; steps: RenderedSteps }) => string); + +interface BaseStep { + id: StepId; + end?: boolean; + placeholder?: string; + inputAttributes?: React.InputHTMLAttributes; + metadata?: Record; +} + +export interface TextStep extends BaseStep { + message: Message; + avatar?: string; + trigger?: Trigger; + delay?: number; + hideInput?: boolean; + hideExtraControl?: boolean; +} + +export interface UserStep extends BaseStep { + user: true; + trigger?: Trigger; + hideExtraControl?: boolean; + /** Return true when the value is valid, or the error message to show */ + validator?: (value: string) => boolean | string; +} + +export interface Option { + label: string; + /** Defaults to the label */ + value?: any; + trigger: Trigger; +} + +export interface OptionsStep extends BaseStep { + options: Option[]; + hideInput?: boolean; + hideExtraControl?: boolean; +} + +export interface CustomStep extends BaseStep { + component: React.ReactElement; + avatar?: string; + replace?: boolean; + waitAction?: boolean; + asMessage?: boolean; + trigger?: Trigger; + delay?: number; + hideInput?: boolean; + hideExtraControl?: boolean; +} + +export interface UpdateStep extends BaseStep { + update: StepId; + trigger: Trigger; +} + +export type Step = TextStep | UserStep | OptionsStep | CustomStep | UpdateStep; + +export interface TriggerNextStepData { + value?: any; + trigger?: Trigger; + hideInput?: boolean; + hideExtraControl?: boolean; +} + +/** Props injected in the components of custom steps */ +export interface CustomComponentProps { + step?: Step & { value?: any }; + steps?: RenderedSteps; + previousStep?: Step & { value?: any }; + triggerNextStep?: (data?: TriggerNextStepData) => void; +} + +export interface HandleEndArgs { + renderedSteps: RenderedStep[]; + steps: RenderedSteps; + values: any[]; +} + +export interface SpeechSynthesisOptions { + enable?: boolean; + lang?: string; + voice?: SpeechSynthesisVoice | null; +} + +export interface ChatBotProps { + steps: Step[]; + avatarStyle?: React.CSSProperties; + botAvatar?: string; + botDelay?: number; + botName?: string; + bubbleOptionStyle?: React.CSSProperties; + bubbleStyle?: React.CSSProperties; + cache?: boolean; + cacheName?: string; + className?: string; + contentStyle?: React.CSSProperties; + controlStyle?: React.CSSProperties; + customDelay?: number; + customStyle?: React.CSSProperties; + enableMobileAutoFocus?: boolean; + enableSmoothScroll?: boolean; + extraControl?: React.ReactElement; + floating?: boolean; + floatingIcon?: string | React.ReactElement; + floatingStyle?: React.CSSProperties; + footerStyle?: React.CSSProperties; + handleEnd?: (args: HandleEndArgs) => void; + headerComponent?: React.ReactElement; + headerTitle?: string; + height?: string; + hideBotAvatar?: boolean; + hideHeader?: boolean; + hideSubmitButton?: boolean; + hideUserAvatar?: boolean; + inputAttributes?: React.InputHTMLAttributes; + inputStyle?: React.CSSProperties; + opened?: boolean; + placeholder?: string; + recognitionEnable?: boolean; + recognitionLang?: string; + recognitionPlaceholder?: string; + speechSynthesis?: SpeechSynthesisOptions; + style?: React.CSSProperties; + submitButtonStyle?: React.CSSProperties; + toggleFloating?: (args: { opened: boolean }) => void; + userAvatar?: string; + userDelay?: number; + width?: string; +} + +declare class ChatBot extends React.Component {} + +export declare const Loading: React.ComponentType; + +export default ChatBot; diff --git a/lib/ChatBot.jsx b/lib/ChatBot.jsx index 94bf1d5a..089967ab 100644 --- a/lib/ChatBot.jsx +++ b/lib/ChatBot.jsx @@ -21,6 +21,20 @@ import { ChatIcon, CloseIcon, SubmitIcon, MicIcon } from './icons'; import { isMobile } from './utils'; import { speakFn } from './speechSynthesis'; +// props used to build the steps, the steps are built again when they change +const STEPS_PROPS = [ + 'steps', + 'botAvatar', + 'botDelay', + 'botName', + 'customDelay', + 'userAvatar', + 'userDelay' +]; + +const pickStepsProps = props => + STEPS_PROPS.reduce((picked, key) => Object.assign(picked, { [key]: props[key] }), {}); + class ChatBot extends Component { /* istanbul ignore next */ constructor(props) { @@ -30,6 +44,10 @@ class ChatBot extends Component { this.input = null; this.supportsScrollBehavior = false; + // keep the last message visible, unless the user scrolled up to read + this.stickToBottom = true; + this.distanceToBottom = 0; + this.renderedStepsCount = 0; this.setContentRef = element => { this.content = element; @@ -47,6 +65,8 @@ class ChatBot extends Component { steps: {}, disabled: true, opened: props.opened || !props.floating, + // a closed floating chatbot starts the conversation when it is opened + started: props.opened || !props.floating, inputValue: '', inputInvalid: false, speaking: false, @@ -54,56 +74,25 @@ class ChatBot extends Component { defaultUserSettings: {} }; - this.speak = speakFn(props.speechSynthesis); + // read the props on every call, so speechSynthesis can change after mount + this.speak = (step, previousValue) => { + const { speechSynthesis } = this.props; + speakFn(speechSynthesis)(step, previousValue); + }; } componentDidMount() { const { steps } = this.props; - const { - botDelay, - botAvatar, - botName, - cache, - cacheName, - customDelay, - enableMobileAutoFocus, - userAvatar, - userDelay - } = this.props; - const chatSteps = {}; + const { cache, cacheName, enableMobileAutoFocus } = this.props; + const { chatSteps, defaultUserSettings } = this.buildSteps(); - const defaultBotSettings = { delay: botDelay, avatar: botAvatar, botName }; - const defaultUserSettings = { - delay: userDelay, - avatar: userAvatar, - hideInput: false, - hideExtraControl: false - }; - const defaultCustomSettings = { delay: customDelay }; + this.builtStepsProps = pickStepsProps(this.props); - for (let i = 0, len = steps.length; i < len; i += 1) { - const step = steps[i]; - let settings = {}; - - if (step.user) { - settings = defaultUserSettings; - } else if (step.message || step.asMessage) { - settings = defaultBotSettings; - } else if (step.component) { - settings = defaultCustomSettings; - } + // copy the parsed step (with defaults), so the step definition is not changed + const firstStep = Object.assign({}, chatSteps[steps[0].id]); - chatSteps[step.id] = Object.assign({}, settings, schema.parse(step)); - } - - schema.checkInvalidIds(chatSteps); - - const firstStep = steps[0]; - - if (firstStep.message) { - const { message } = firstStep; - firstStep.message = typeof message === 'function' ? message() : message; - chatSteps[firstStep.id].message = firstStep.message; + if (typeof firstStep.message === 'function') { + firstStep.message = firstStep.message({ previousValue: undefined, steps: {} }); } const { recognitionEnable } = this.state; @@ -121,7 +110,10 @@ class ChatBot extends Component { this.supportsScrollBehavior = 'scrollBehavior' in document.documentElement.style; if (this.content) { - this.content.addEventListener('DOMNodeInserted', this.onNodeInserted); + if (typeof MutationObserver !== 'undefined') { + this.contentObserver = new MutationObserver(this.onNodeInserted); + this.contentObserver.observe(this.content, { childList: true, subtree: true }); + } window.addEventListener('resize', this.onResize); } @@ -135,7 +127,8 @@ class ChatBot extends Component { () => { // focus input if last step cached is a user step this.setState({ disabled: false }, () => { - if (enableMobileAutoFocus || !isMobile()) { + const { opened } = this.state; + if (opened && (enableMobileAutoFocus || !isMobile())) { if (this.input) { this.input.focus(); } @@ -154,27 +147,131 @@ class ChatBot extends Component { }); } + componentDidUpdate() { + const { renderedSteps } = this.state; + + // a new message always brings the conversation to the bottom + if (renderedSteps.length > this.renderedStepsCount) { + this.stickToBottom = true; + this.scrollToBottom(); + } + this.renderedStepsCount = renderedSteps.length; + } + static getDerivedStateFromProps(props, state) { const { opened, toggleFloating } = props; if (toggleFloating !== undefined && opened !== undefined && opened !== state.opened) { return { - ...state, - opened + opened, + started: state.started || opened }; } - return state; + return null; } componentWillUnmount() { - if (this.content) { - this.content.removeEventListener('DOMNodeInserted', this.onNodeInserted); - window.removeEventListener('resize', this.onResize); + if (this.contentObserver) { + this.contentObserver.disconnect(); + this.contentObserver = null; } + window.removeEventListener('resize', this.onResize); } - onNodeInserted = event => { - const { currentTarget: target } = event; + buildSteps = () => { + const { botAvatar, botDelay, botName, customDelay, steps, userAvatar, userDelay } = this.props; + const chatSteps = {}; + + const defaultBotSettings = { delay: botDelay, avatar: botAvatar, botName }; + const defaultUserSettings = { + delay: userDelay, + avatar: userAvatar, + hideInput: false, + hideExtraControl: false + }; + const defaultCustomSettings = { delay: customDelay }; + + for (let i = 0, len = steps.length; i < len; i += 1) { + const step = steps[i]; + let settings = {}; + + if (step.user) { + settings = defaultUserSettings; + } else if (step.message || step.asMessage) { + settings = defaultBotSettings; + } else if (step.component) { + settings = defaultCustomSettings; + } + + const chatStep = Object.assign({}, settings, schema.parse(step)); + + if (Array.isArray(chatStep.options)) { + // options without value are selected by their label + chatStep.options = chatStep.options.map(option => + option.value === undefined ? Object.assign({}, option, { value: option.label }) : option + ); + } + + chatSteps[step.id] = chatStep; + } + + schema.checkInvalidIds(chatSteps); + + return { chatSteps, defaultUserSettings }; + }; + + // build the steps again if the props changed since they were built, so the + // steps that were not rendered yet use the new definitions + getLatestSteps = () => { + const { steps, defaultUserSettings } = this.state; + const changed = STEPS_PROPS.some(key => this.builtStepsProps[key] !== this.props[key]); + + if (!changed) { + return { steps, defaultUserSettings }; + } + + this.builtStepsProps = pickStepsProps(this.props); + + try { + const { chatSteps, defaultUserSettings: newDefaultUserSettings } = this.buildSteps(); + this.setState({ steps: chatSteps, defaultUserSettings: newDefaultUserSettings }); + return { steps: chatSteps, defaultUserSettings: newDefaultUserSettings }; + } catch (error) { + // keep the conversation working with the last valid steps + // eslint-disable-next-line no-console + console.error(error); + return { steps, defaultUserSettings }; + } + }; + + onNodeInserted = () => { + if (this.stickToBottom) { + this.scrollToBottom(); + } + }; + + onContentScroll = () => { + const target = this.content; + if (!target) { + return; + } + + const distance = target.scrollHeight - target.scrollTop - target.clientHeight; + if (distance <= 40) { + this.stickToBottom = true; + } else if (distance > this.distanceToBottom) { + // moving away from the bottom, so it was the user (smooth scroll only goes down) + this.stickToBottom = false; + } + this.distanceToBottom = distance; + }; + + scrollToBottom = () => { const { enableSmoothScroll } = this.props; + const target = this.content; + + if (!target) { + return; + } if (enableSmoothScroll && this.supportsScrollBehavior) { target.scroll({ @@ -188,7 +285,9 @@ class ChatBot extends Component { }; onResize = () => { - this.content.scrollTop = this.content.scrollHeight; + if (this.content && this.stickToBottom) { + this.content.scrollTop = this.content.scrollHeight; + } }; onRecognitionChange = value => { @@ -196,8 +295,12 @@ class ChatBot extends Component { }; onRecognitionEnd = () => { - this.setState({ speaking: false }); - this.handleSubmitButton(); + this.setState({ speaking: false }, () => { + // submitting an empty value would start the recognition again forever + if (!this.isInputValueEmpty()) { + this.submitUserMessage(); + } + }); }; onRecognitionStop = () => { @@ -241,7 +344,8 @@ class ChatBot extends Component { triggerNextStep = data => { const { enableMobileAutoFocus } = this.props; - const { defaultUserSettings, previousSteps, renderedSteps, steps } = this.state; + const { previousSteps, renderedSteps } = this.state; + const { defaultUserSettings, steps } = this.getLatestSteps(); let { currentStep, previousStep } = this.state; const isEnd = currentStep.end; @@ -263,6 +367,9 @@ class ChatBot extends Component { this.handleEnd(); } else if (currentStep.options && data) { const option = currentStep.options.filter(o => o.value === data.value)[0]; + if (!option) { + return; + } const trigger = this.getTriggeredStep(option.trigger, currentStep.value); delete currentStep.options; @@ -298,9 +405,10 @@ class ChatBot extends Component { nextStep = Object.assign({}, steps[updateStep.update]); if (nextStep.options) { - for (let i = 0, len = nextStep.options.length; i < len; i += 1) { - nextStep.options[i].trigger = updateStep.trigger; - } + // copy the options so the original step keeps its own triggers + nextStep.options = nextStep.options.map(option => + Object.assign({}, option, { trigger: updateStep.trigger }) + ); } else { nextStep.trigger = updateStep.trigger; } @@ -443,7 +551,8 @@ class ChatBot extends Component { }; submitUserMessage = () => { - const { defaultUserSettings, inputValue, previousSteps, renderedSteps } = this.state; + const { inputValue, previousSteps, renderedSteps } = this.state; + const { defaultUserSettings } = this.getLatestSteps(); let { currentStep } = this.state; const isInvalid = currentStep.validator && this.checkInvalidInput(); @@ -515,13 +624,20 @@ class ChatBot extends Component { return false; }; + handleButtonKeyDown = (event, opened) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleChatBot(opened); + } + }; + toggleChatBot = opened => { const { toggleFloating } = this.props; if (toggleFloating) { toggleFloating({ opened }); } else { - this.setState({ opened }); + this.setState(state => ({ opened, started: state.started || opened })); } }; @@ -595,6 +711,7 @@ class ChatBot extends Component { inputValue, opened, renderedSteps, + started, speaking, recognitionEnable } = this.state; @@ -625,7 +742,14 @@ class ChatBot extends Component {
{headerTitle} {floating && ( - this.toggleChatBot(false)}> + this.toggleChatBot(false)} + onKeyDown={event => this.handleButtonKeyDown(event, false)} + > )} @@ -641,8 +765,12 @@ class ChatBot extends Component { }); } - const icon = - (this.isInputValueEmpty() || speaking) && recognitionEnable ? : ; + const showMic = (this.isInputValueEmpty() || speaking) && recognitionEnable; + const icon = showMic ? : ; + let submitLabel = 'Send message'; + if (showMic) { + submitLabel = speaking ? 'Stop voice input' : 'Start voice input'; + } const inputPlaceholder = speaking ? recognitionPlaceholder @@ -657,7 +785,12 @@ class ChatBot extends Component { className="rsc-float-button" style={floatingStyle} opened={opened} + role="button" + tabIndex={opened ? -1 : 0} + aria-label="Open chat" + aria-hidden={opened} onClick={() => this.toggleChatBot(true)} + onKeyDown={event => this.handleButtonKeyDown(event, true)} > {typeof floatingIcon === 'string' ? : floatingIcon} @@ -675,17 +808,19 @@ class ChatBot extends Component { - {renderedSteps.map(this.renderStep)} + {started && renderedSteps.map(this.renderStep)}