├── src ├── polyfill.js ├── index.css ├── reducers │ ├── index.js │ ├── events.js │ ├── users.js │ └── app.js ├── constants │ └── chatActionTypes.js ├── components │ ├── Minimized.js │ ├── Maximized.js │ └── App.js ├── index.js ├── store.js ├── actions │ └── chatActions.js ├── containers │ └── AppContainer.js ├── logo.svg ├── registerServiceWorker.js └── sagas │ └── index.js ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .env ├── .gitignore ├── README.md └── package.json /src/polyfill.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-native-reassign 2 | window.global = window 3 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livechat/chat-widget-sample/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_BOTENGINE_CLIENT_TOKEN=4cc01931fa302f2b50bcdeb70e961fc47d156face3bdb8b616e3e9f3e18e0cc8 2 | REACT_APP_BOTENGINE_STORY_ID=5a439b8bb9677d000790134d 3 | REACT_APP_LIVECHAT_LICENSE=1520 4 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import events from './events' 3 | import users from './users' 4 | import app from './app' 5 | 6 | const rootReducer = combineReducers({ 7 | app, 8 | events, 9 | users, 10 | }) 11 | 12 | export default rootReducer 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/constants/chatActionTypes.js: -------------------------------------------------------------------------------- 1 | export const NEW_MESSAGE = 'NEW_MESSAGE' 2 | export const CHAT_STARTED = 'CHAT_STARTED' 3 | export const CHAT_ENDED = 'CHAT_ENDED' 4 | export const SEND_MESSAGE = 'SEND_MESSAGE' 5 | export const NEW_USER = 'NEW_AGENT' 6 | export const OWN_DATA_RECEIVED = 'OWN_DATA_RECEIVED' 7 | export const CHANGE_CHAT_SERVICE = 'CHANGE_CHAT_SERVICE' 8 | export const RATE_GOOD = 'RATE_GOOD' 9 | export const RATE_BAD = 'RATE_BAD' 10 | export const CHAT_RATED = 'CHAT_RATED' 11 | -------------------------------------------------------------------------------- /src/components/Minimized.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IconButton, ChatIcon } from '@livechat/ui-kit' 3 | 4 | const Minimized = ({ maximize }) => ( 5 |
19 | 20 | 21 | 22 |
23 | ) 24 | 25 | export default Minimized 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render } from 'react-dom' 3 | import { PersistGate } from 'redux-persist/lib/integration/react' 4 | import { Provider } from 'react-redux' 5 | import configureStore from './store' 6 | import rootSaga from './sagas' 7 | import AppContainer from './containers/AppContainer' 8 | 9 | const { store, persistor } = configureStore() 10 | store.runSaga(rootSaga, store) 11 | 12 | const root = document.getElementById('root') 13 | 14 | render( 15 | 16 | 17 | 18 | 19 | , 20 | root, 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat widget sample 2 | Sample chat widget built with [LiveChat React chat UI kit](https://docs.livechatinc.com/react-chat-ui-kit/). In this widget, [BotEngine](https://www.botengine.ai/) handles the incoming chats. When the bot returns `LiveChat.transfer` action, the chat is transferred to a human agent together with the transcript of the initial conversation with 3 | the bot. 4 | 5 | The sample app uses [Visitor SDK](https://docs.livechatinc.com/visitor-sdk/) to communicate with LiveChat and [the API](https://docs.botengine.ai/api/introduction) to connect with BotEngine. 6 | 7 | ## Installation 8 | 9 | Copy `.env` file as `.env.local` and fill required variables: 10 | 11 | * REACT_APP_BOTENGINE_CLIENT_TOKEN - client token for [BotEngine](https://www.botengine.ai/) account 12 | * REACT_APP_BOTENGINE_STORY_ID - id of choosen botengine story 13 | * REACT_APP_LIVECHAT_LICENSE - [LiveChat](https://livechatinc.com) license number 14 | 15 | -------------------------------------------------------------------------------- /src/reducers/events.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from 'redux-create-reducer' 2 | import * as actionTypes from '../constants/chatActionTypes' 3 | 4 | const initialState = [ 5 | { 6 | id: 'bot-message', 7 | authorId: 'bot', 8 | text: 'Hello, how can I help you?', 9 | timestamp: Date.now(), 10 | }, 11 | ] 12 | 13 | export default createReducer(initialState, { 14 | [actionTypes.NEW_MESSAGE](state, action) { 15 | if (action.payload.customId === 'VISITOR_CHAT_HISTORY') { 16 | return state 17 | } 18 | const foundEvent = state.filter(event => { 19 | return ( 20 | (event.customId && event.customId === action.payload.customId) || (event.id && event.id === action.payload.id) 21 | ) 22 | }) 23 | if (!foundEvent.length) { 24 | return [...state, action.payload] 25 | } 26 | return state 27 | }, 28 | [actionTypes.SEND_MESSAGE](state, action) { 29 | if (action.payload.customId === 'VISITOR_CHAT_HISTORY') { 30 | return state 31 | } 32 | return [ 33 | ...state, 34 | { 35 | ...action.payload, 36 | status: 'SENDING', 37 | own: true, 38 | }, 39 | ] 40 | }, 41 | }) 42 | export const getEvents = state => state.events 43 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore, compose } from 'redux' 2 | import { persistStore, persistCombineReducers } from 'redux-persist' 3 | import storage from 'redux-persist/es/storage' 4 | import createSagaMiddleware from 'redux-saga' 5 | import events from './reducers/events' 6 | import users from './reducers/users' 7 | import app from './reducers/app' 8 | 9 | const config = { 10 | key: 'root', 11 | storage, 12 | } 13 | 14 | const reducer = persistCombineReducers(config, { 15 | app, 16 | events, 17 | users, 18 | }) 19 | 20 | const sagaMiddleware = createSagaMiddleware() 21 | 22 | const composeCreateStore = () => 23 | compose(applyMiddleware(sagaMiddleware), window.devToolsExtension ? window.devToolsExtension() : fn => fn)( 24 | createStore, 25 | ) 26 | 27 | const configureStore = port => { 28 | const finalCreateStore = composeCreateStore(port) 29 | const store = { 30 | ...finalCreateStore(reducer), 31 | runSaga: sagaMiddleware.run, 32 | } 33 | 34 | // if (module.hot) { 35 | // module.hot.accept('./reducers', () => store.replaceReducer(reducer)) 36 | // } 37 | const persistor = persistStore(store) 38 | return { persistor, store } 39 | } 40 | 41 | export default configureStore 42 | -------------------------------------------------------------------------------- /src/reducers/users.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from 'redux-create-reducer' 2 | import * as actionTypes from '../constants/chatActionTypes' 3 | 4 | const initialState = { 5 | byIds: { 6 | bot: { 7 | id: 'bot', 8 | name: 'Bot', 9 | avatarUrl: 10 | 'https://static.staging.livechatinc.com/1520/P10064EDGF/7970c9d036275c2ee9282d15535ef57b/botengine-avatar.png', 11 | }, 12 | }, 13 | ownId: null, 14 | currentAgent: 'bot', 15 | } 16 | 17 | export default createReducer(initialState, { 18 | [actionTypes.NEW_USER](state, action) { 19 | return { 20 | ...state, 21 | currentAgent: action.payload.id, 22 | byIds: { 23 | ...state.byIds, 24 | [action.payload.id]: action.payload, 25 | }, 26 | } 27 | }, 28 | [actionTypes.OWN_DATA_RECEIVED](state, action) { 29 | return { 30 | ...state, 31 | ownId: action.payload.id, 32 | byIds: { 33 | ...state.byIds, 34 | [action.payload.id]: { 35 | ...action.payload, 36 | name: 'Client', 37 | }, 38 | }, 39 | } 40 | }, 41 | }) 42 | 43 | export const getUsers = state => state.users.byIds 44 | export const getOwnId = state => state.users.ownId 45 | export const getCurrentAgent = state => state.users.byIds[state.users.currentAgent] 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-widget-sample", 3 | "version": "0.0.1", 4 | "description": "Sample chat widget built with UI kit", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/livechat/chat-widget-sample.git" 9 | }, 10 | "keywords": [ 11 | "livechat", 12 | "chat", 13 | "widget" 14 | ], 15 | "contributors": [ 16 | "Konrad Kruk ", 17 | "Mateusz Burzyński " 18 | ], 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/livechat/chat-widget-sample/issues" 22 | }, 23 | "homepage": "https://github.com/livechat/chat-widget-sample#readme", 24 | "dependencies": { 25 | "@livechat/livechat-visitor-sdk": "0.35.2", 26 | "@livechat/ui-kit": "0.2.13", 27 | "react": "^15.5.4", 28 | "react-dom": "^15.5.4", 29 | "react-redux": "^5.0.2", 30 | "redux": "^3.6.0", 31 | "redux-create-reducer": "^1.1.0", 32 | "redux-persist": "^5.4.0", 33 | "redux-saga": "^0.15.6", 34 | "@livechat/saga-utils": "0.1.1" 35 | }, 36 | "devDependencies": { 37 | "react-scripts": "1.0.17" 38 | }, 39 | "scripts": { 40 | "start": "react-scripts start", 41 | "build": "react-scripts build", 42 | "test": "react-scripts test --env=jsdom", 43 | "eject": "react-scripts eject" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/reducers/app.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from 'redux-create-reducer' 2 | import * as chatActionTypes from '../constants/chatActionTypes' 3 | 4 | const initialState = { 5 | chatState: 'NOT_CHATTING', 6 | widgetState: 'MAXIMIZED', 7 | chatService: 'botEngine', 8 | rate: 'none', 9 | } 10 | 11 | export default createReducer(initialState, { 12 | [chatActionTypes.CHAT_STARTED](state) { 13 | return { 14 | ...state, 15 | chatState: 'CHATTING', 16 | chatService: 'LiveChat', 17 | } 18 | }, 19 | [chatActionTypes.CHANGE_CHAT_SERVICE](state, { payload }) { 20 | return { 21 | ...state, 22 | chatService: payload.chatService, 23 | } 24 | }, 25 | [chatActionTypes.CHAT_ENDED](state) { 26 | return { 27 | ...state, 28 | chatState: 'ENDED', 29 | } 30 | }, 31 | [chatActionTypes.CHAT_RATED](state, { payload }) { 32 | return { 33 | ...state, 34 | rate: payload.rate || state.rate, 35 | } 36 | }, 37 | [chatActionTypes.RATE_GOOD](state, { payload }) { 38 | return { 39 | ...state, 40 | rate: 'good', 41 | } 42 | }, 43 | [chatActionTypes.RATE_BAD](state, { payload }) { 44 | return { 45 | ...state, 46 | rate: 'bad', 47 | } 48 | }, 49 | }) 50 | 51 | export const getChatState = state => state.app.chatState 52 | export const getWidgetState = state => state.app.widgetState 53 | export const getChatService = state => state.app.chatService 54 | export const getRate = state => state.app.rate 55 | -------------------------------------------------------------------------------- /src/actions/chatActions.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/chatActionTypes' 2 | export const newMessage = ({ id, authorId, customId, text, buttons, title, imageUrl, timestamp }) => ({ 3 | type: actionTypes.NEW_MESSAGE, 4 | payload: { 5 | id, 6 | authorId, 7 | customId, 8 | text, 9 | buttons, 10 | title, 11 | imageUrl, 12 | timestamp, 13 | }, 14 | }) 15 | 16 | export const sendMessage = ({ text, customId }) => ({ 17 | type: actionTypes.SEND_MESSAGE, 18 | payload: { 19 | text, 20 | customId: customId || String(Math.random()), 21 | timestamp: new Date(), 22 | }, 23 | }) 24 | 25 | export const newUser = ({ id, name, email, avatarUrl }) => ({ 26 | type: actionTypes.NEW_USER, 27 | payload: { 28 | id, 29 | name, 30 | email, 31 | avatarUrl, 32 | }, 33 | }) 34 | 35 | export const ownDataReceived = ({ id }) => ({ 36 | type: actionTypes.OWN_DATA_RECEIVED, 37 | payload: { 38 | id, 39 | }, 40 | }) 41 | 42 | export const chatEnded = () => ({ 43 | type: actionTypes.CHAT_ENDED, 44 | }) 45 | 46 | export const chatStarted = ({ chatId }) => ({ 47 | type: actionTypes.CHAT_STARTED, 48 | payload: { 49 | chatId, 50 | }, 51 | }) 52 | 53 | export const changeChatService = ({ chatService }) => ({ 54 | type: actionTypes.CHANGE_CHAT_SERVICE, 55 | payload: { 56 | chatService, 57 | }, 58 | }) 59 | 60 | export const rateGood = () => ({ 61 | type: actionTypes.RATE_GOOD, 62 | }) 63 | 64 | export const rateBad = () => ({ 65 | type: actionTypes.RATE_BAD, 66 | }) 67 | 68 | export const chatRated = ({ rate }) => ({ 69 | type: actionTypes.CHAT_RATED, 70 | payload: { 71 | rate, 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 30 | 31 | 32 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/containers/AppContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import App from '../components/App' 3 | import { getEvents } from '../reducers/events' 4 | import { getUsers, getOwnId, getCurrentAgent } from '../reducers/users' 5 | import { getRate, getChatState } from '../reducers/app' 6 | import { sendMessage, rateGood, rateBad } from '../actions/chatActions' 7 | 8 | const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 9 | 10 | const parseTimestamp = timestamp => { 11 | const date = new Date(timestamp) 12 | return `${ date.getDate() } ${ months[date.getMonth()] } ${ date.getHours() }:${ date.getMinutes() }` 13 | } 14 | 15 | const parseMessages = messages => 16 | messages 17 | .map(message => ({ 18 | ...message, 19 | parsedDate: parseTimestamp(message.timestamp), 20 | })) 21 | .reduce( 22 | (result, current) => { 23 | const previous = result[result.length - 1] 24 | if (!previous.length || previous[previous.length - 1].authorId === current.authorId) { 25 | result[result.length - 1].push(current) 26 | return result 27 | } 28 | result.push([current]) 29 | return result 30 | }, 31 | [[]], 32 | ) 33 | 34 | const mapStateToProps = state => { 35 | return { 36 | events: parseMessages(getEvents(state)), 37 | users: getUsers(state), 38 | ownId: getOwnId(state), 39 | currentAgent: getCurrentAgent(state), 40 | rate: getRate(state), 41 | chatState: getChatState(state), 42 | } 43 | } 44 | 45 | const mapDispatchToProps = dispatch => ({ 46 | onMessageSend: data => { 47 | dispatch( 48 | sendMessage({ 49 | text: data, 50 | }), 51 | ) 52 | }, 53 | sendMessage: text => 54 | dispatch( 55 | sendMessage({ 56 | text, 57 | }), 58 | ), 59 | rateGood: () => { 60 | dispatch(rateGood()) 61 | }, 62 | rateBad: () => { 63 | dispatch(rateBad()) 64 | } 65 | }) 66 | 67 | const AppContainer = connect(mapStateToProps, mapDispatchToProps)(App) 68 | export default AppContainer 69 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | } else { 39 | // Is not local host. Just register service worker 40 | registerValidSW(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/components/Maximized.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Avatar, 4 | TitleBar, 5 | TextInput, 6 | MessageList, 7 | Message, 8 | MessageText, 9 | AgentBar, 10 | Title, 11 | Subtitle, 12 | MessageGroup, 13 | MessageButtons, 14 | MessageButton, 15 | MessageTitle, 16 | MessageMedia, 17 | TextComposer, 18 | Row, 19 | Fill, 20 | Fit, 21 | IconButton, 22 | SendButton, 23 | EmojiIcon, 24 | CloseIcon, 25 | Column, 26 | RateGoodIcon, 27 | RateBadIcon, 28 | Bubble, 29 | } from '@livechat/ui-kit' 30 | 31 | const getAvatarForUser = (userId, users) => { 32 | const foundUser = users[userId] 33 | if (foundUser && foundUser.avatarUrl) { 34 | return foundUser.avatarUrl 35 | } 36 | return null 37 | } 38 | 39 | const parseUrl = (url) => url && 'https://' + url.replace(/^(http(s)?\:\/\/)/, '').replace(/^\/\//, '') 40 | 41 | const Maximized = ({ 42 | chatState, 43 | events, 44 | onMessageSend, 45 | users, 46 | ownId, 47 | currentAgent, 48 | minimize, 49 | maximizeChatWidget, 50 | sendMessage, 51 | rateGood, 52 | rateBad, 53 | rate, 54 | }) => { 55 | return ( 56 |
63 | 66 | 67 | , 68 | ]} 69 | title="Welcome to LiveChat" 70 | /> 71 | {currentAgent && ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | {currentAgent.name} 79 | Support hero 80 | 81 | 82 | {chatState === 'CHATTING' && 83 | 84 | 85 | 88 | 89 | 90 | 93 | 94 | 95 | } 96 | 97 | 98 | 99 | )} 100 |
107 | 108 | {events.map((messageGroup, index) => ( 109 | 110 | {messageGroup.map(message => ( 111 | 117 | 118 | {message.title && } 119 | {message.text && {message.text}} 120 | {message.imageUrl && ( 121 | 122 | 123 | 124 | )} 125 | {message.buttons && 126 | message.buttons.length !== 0 && ( 127 | 128 | {message.buttons.map((button, buttonIndex) => ( 129 | { 133 | sendMessage(button.postback) 134 | }} 135 | /> 136 | ))} 137 | 138 | )} 139 | 140 | 141 | ))} 142 | 143 | ))} 144 | 145 |
146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 |
165 | {'Powered by LiveChat'} 166 |
167 |
168 | ) 169 | } 170 | 171 | export default Maximized 172 | -------------------------------------------------------------------------------- /src/sagas/index.js: -------------------------------------------------------------------------------- 1 | import fetch from 'unfetch' 2 | import { init } from '@livechat/livechat-visitor-sdk' 3 | import { call, takeEvery, fork, select, put, take, all } from 'redux-saga/effects' 4 | import { REHYDRATE, PURGE } from 'redux-persist' 5 | import { getChatService } from '../reducers/app' 6 | import { 7 | newMessage, 8 | newUser, 9 | ownDataReceived, 10 | chatEnded, 11 | chatStarted, 12 | changeChatService, 13 | sendMessage, 14 | chatRated, 15 | } from '../actions/chatActions' 16 | import * as actionTypes from '../constants/chatActionTypes' 17 | import { getEvents } from '../reducers/events' 18 | import { getUsers, getOwnId } from '../reducers/users' 19 | 20 | const botEngineClientToken = process.env.REACT_APP_BOTENGINE_CLIENT_TOKEN 21 | const sessionId = String(Math.random()) 22 | 23 | const sendQueryToBotEngine = query => 24 | fetch('https://api.botengine.ai/query', { 25 | headers: { 26 | authorization: `Bearer ${ botEngineClientToken }`, 27 | 'Content-Type': 'application/json', 28 | }, 29 | method: 'POST', 30 | body: JSON.stringify({ 31 | sessionId: sessionId, 32 | query: query, 33 | storyId: process.env.REACT_APP_BOTENGINE_STORY_ID, 34 | }), 35 | }).then(response => response.json()) 36 | 37 | function* transferToLiveChat() { 38 | const events = yield select(getEvents) 39 | const users = yield select(getUsers) 40 | const ownId = yield select(getOwnId) 41 | const parsedEvents = events 42 | .map(event => { 43 | const userName = (users[event.authorId] && users[event.authorId].name) || users[ownId].name 44 | const text = event.text || event.title 45 | return `${ userName }: ${ text }` 46 | }) 47 | .join(' \n') 48 | yield put( 49 | changeChatService({ 50 | chatService: 'LiveChat', 51 | }), 52 | ) 53 | yield put( 54 | sendMessage({ 55 | customId: 'VISITOR_CHAT_HISTORY', 56 | text: parsedEvents, 57 | }), 58 | ) 59 | } 60 | 61 | function* handleSendMessage(sdk, { payload }) { 62 | const chatService = yield select(getChatService) 63 | if (chatService === 'LiveChat') { 64 | try { 65 | yield call(sdk.sendMessage, { 66 | customId: payload.customId, 67 | text: payload.text, 68 | }) 69 | } catch (error) { 70 | console.log('> ERROR', error) 71 | } 72 | return 73 | } 74 | if (chatService === 'botEngine') { 75 | try { 76 | const botEngineResponse = yield call(sendQueryToBotEngine, payload.text) 77 | if ( 78 | !botEngineResponse.result || 79 | !botEngineResponse.result.fulfillment || 80 | !botEngineResponse.result.fulfillment.length 81 | ) { 82 | yield call(transferToLiveChat) 83 | return 84 | } 85 | const messagesToAdd = botEngineResponse.result.fulfillment.map(fulfillmentItem => { 86 | const message = { 87 | id: Math.random(), 88 | authorId: 'bot', 89 | } 90 | if (fulfillmentItem.message) { 91 | message.text = fulfillmentItem.message 92 | } 93 | if (fulfillmentItem.buttons) { 94 | message.buttons = fulfillmentItem.buttons 95 | } 96 | if (fulfillmentItem.title) { 97 | message.title = fulfillmentItem.title 98 | } 99 | if (fulfillmentItem.imageUrl) { 100 | message.imageUrl = fulfillmentItem.imageUrl 101 | } 102 | if (fulfillmentItem.replies) { 103 | message.buttons = fulfillmentItem.replies.map(reply => ({ 104 | title: reply, 105 | })) 106 | } 107 | if (botEngineResponse.timestamp) { 108 | message.timestamp = botEngineResponse.timestamp 109 | } 110 | return newMessage(message) 111 | }) 112 | yield all(messagesToAdd.map(action => put(action))) 113 | if (botEngineResponse.result.interaction.action === 'livechat.transfer') { 114 | yield call(transferToLiveChat) 115 | } 116 | } catch (error) { 117 | console.log('>> BOTENGINEERROR', error) 118 | yield call(transferToLiveChat) 119 | } 120 | } 121 | } 122 | 123 | function* handleRateGood(sdk) { 124 | yield call(sdk.rateChat, { 125 | rate: 'good', 126 | }) 127 | } 128 | 129 | function* handleRateBad(sdk) { 130 | yield call(sdk.rateChat, { 131 | rate: 'bad', 132 | }) 133 | } 134 | 135 | function* handleCallbacks(store) { 136 | const sdk = init({ 137 | license: process.env.REACT_APP_LIVECHAT_LICENSE, 138 | }) 139 | sdk.on('new_message', data => { 140 | store.dispatch(newMessage(data)) 141 | }) 142 | sdk.on('agent_changed', data => { 143 | store.dispatch(newUser(data)) 144 | }) 145 | sdk.on('visitor_data', data => { 146 | store.dispatch(ownDataReceived(data)) 147 | }) 148 | sdk.on('chat_ended', () => { 149 | console.log('>chat_ended') 150 | store.dispatch(chatEnded()) 151 | }) 152 | sdk.on('chat_started', data => { 153 | store.dispatch(chatStarted(data)) 154 | }) 155 | sdk.on('status_changed', data => { 156 | console.log('> status_changed', data) 157 | }) 158 | sdk.on('visitor_queued', data => { 159 | console.log('> visitor_queued', data) 160 | }) 161 | sdk.on('typing_indicator', data => { 162 | console.log('> typing_indicator', data) 163 | }) 164 | sdk.on('connection_status_changed', data => { 165 | console.log('> connection_status_changed', data) 166 | }) 167 | sdk.on('chat_rated', data => { 168 | store.dispatch(chatRated({ 169 | rate: data.rate, 170 | })) 171 | }) 172 | 173 | yield takeEvery(actionTypes.SEND_MESSAGE, handleSendMessage, sdk) 174 | yield takeEvery(actionTypes.RATE_GOOD, handleRateGood, sdk) 175 | yield takeEvery(actionTypes.RATE_BAD, handleRateBad, sdk) 176 | } 177 | 178 | const getPersistSelector = state => state._persist && state._persist.rehydrated 179 | 180 | export default function*(store) { 181 | if (!getPersistSelector) { 182 | yield take(REHYDRATE) 183 | } 184 | yield fork(handleCallbacks, store) 185 | } 186 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Maximized from './Maximized' 3 | import Minimized from './Minimized' 4 | import { ThemeProvider, FixedWrapper, darkTheme, elegantTheme, purpleTheme, defaultTheme } from '@livechat/ui-kit' 5 | 6 | const themes = { 7 | defaultTheme: { 8 | FixedWrapperMaximized: { 9 | css: { 10 | boxShadow: '0 0 1em rgba(0, 0, 0, 0.1)', 11 | }, 12 | }, 13 | }, 14 | purpleTheme: { 15 | ...purpleTheme, 16 | TextComposer: { 17 | ...purpleTheme.TextComposer, 18 | css: { 19 | ...purpleTheme.TextComposer.css, 20 | marginTop: '1em', 21 | }, 22 | }, 23 | OwnMessage: { 24 | ...purpleTheme.OwnMessage, 25 | secondaryTextColor: '#fff', 26 | }, 27 | }, 28 | elegantTheme: { 29 | ...elegantTheme, 30 | Message: { 31 | ...darkTheme.Message, 32 | secondaryTextColor: '#fff', 33 | }, 34 | OwnMessage: { 35 | ...darkTheme.OwnMessage, 36 | secondaryTextColor: '#fff', 37 | }, 38 | }, 39 | darkTheme: { 40 | ...darkTheme, 41 | Message: { 42 | ...darkTheme.Message, 43 | css: { 44 | ...darkTheme.Message.css, 45 | color: '#fff', 46 | }, 47 | }, 48 | OwnMessage: { 49 | ...darkTheme.OwnMessage, 50 | secondaryTextColor: '#fff', 51 | }, 52 | TitleBar: { 53 | ...darkTheme.TitleBar, 54 | css: { 55 | ...darkTheme.TitleBar.css, 56 | padding: '1em', 57 | }, 58 | }, 59 | }, 60 | } 61 | 62 | const commonThemeButton = { 63 | fontSize: '16px', 64 | padding: '1em', 65 | borderRadius: '.6em', 66 | margin: '1em', 67 | cursor: 'pointer', 68 | outline: 'none', 69 | border: 0, 70 | } 71 | 72 | const themePurpleButton = { 73 | ...commonThemeButton, 74 | background: 'linear-gradient(to right, #6D5BBA, #8D58BF)', 75 | color: '#fff', 76 | } 77 | 78 | const themeDarkButton = { 79 | ...commonThemeButton, 80 | background: 'rgba(0, 0, 0, 0.8)', 81 | color: '#fff', 82 | } 83 | 84 | const themeDefaultButton = { 85 | ...commonThemeButton, 86 | background: '#427fe1', 87 | color: '#fff', 88 | } 89 | 90 | const themeElegantButton = { 91 | ...commonThemeButton, 92 | background: '#000', 93 | color: '#D9A646', 94 | } 95 | 96 | 97 | class App extends React.Component { 98 | state = { 99 | theme: 'defaultTheme' 100 | } 101 | 102 | handleThemeChange = ({ target }) => { 103 | console.log('target.name', target.name) 104 | this.setState({ 105 | theme: target.name + 'Theme' , 106 | }) 107 | } 108 | 109 | render() { 110 | return ( 111 | 112 |
114 |
119 |
123 |

128 | Chat widget 129 |

130 |

LiveChat React UI Kit sample

134 |
135 |
136 |
140 |

Sample chat widget built with LiveChat React chat UI kit. In this widget, BotEngine handles the incoming chats. When the bot returns `LiveChat.transfer` action, the chat is transferred to a human agent together with the transcript of the initial conversation with the bot.

141 |

The sample app uses Visitor SDK to communicate with LiveChat and the API to connect with BotEngine.

142 |

Source code is avaible at Github.

143 |

Change components theme:

144 | 147 | 150 | 153 | 156 |
157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 |
166 |
167 | ) 168 | } 169 | } 170 | 171 | export default App 172 | --------------------------------------------------------------------------------