/node_modules/fbjs"
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var bodyParser = require('body-parser');
3 | var cookieParser = require('cookie-parser');
4 | var path = require('path');
5 | var fs = require('fs');
6 | var app = express();
7 |
8 | var tokens = require('./server/routes/tokens');
9 | var calls = require('./server/routes/calls');
10 | var sms = require('./server/routes/sms');
11 | var taskrouter = require('./server/routes/taskrouter');
12 | //var chat = require('./server/routes/chat');
13 |
14 | var port = process.env.PORT || 3000;
15 | app.use(bodyParser.urlencoded({
16 | extended: true
17 | }));
18 | app.use( bodyParser.json() );
19 | app.use(express.static(path.join(__dirname, 'build')));
20 | app.use(cookieParser());
21 |
22 | app.use(function timeLog(req, res, next) {
23 | console.log('Main Request - Time: ', Date.now());
24 | console.log(req.originalUrl);
25 | next();
26 | });
27 |
28 | //setup routes all scoped to /api
29 | app.use('/api/tokens', tokens);
30 | app.use('/api/calls', calls);
31 | app.use('/api/sms', sms);
32 | app.use('/api/taskrouter', taskrouter);
33 | //app.use('/api/chat', chat);
34 |
35 |
36 | app.listen(port, function (err){
37 | if (err) throw err;
38 | console.log("Server running on port " + port);
39 | });
40 |
--------------------------------------------------------------------------------
/server/routes/calls.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | var VoiceResponse = require('twilio').twiml.VoiceResponse;
5 | var config = require('../../twilio.config');
6 |
7 | router.post('/', function(req, res) {
8 | const resp = new VoiceResponse();
9 | const type = {skill: 'skill_1', accelerator: 'false'};
10 | const json = JSON.stringify(type);
11 |
12 | resp.enqueueTask({
13 | workflowSid: config.workflowSid,
14 | }).task({priority: '1'}, json)
15 |
16 | res.send(resp.toString());
17 |
18 | });
19 |
20 | router.post('/events', function(req, res) {
21 | console.log('*********************************************************')
22 | console.log('*********************************************************')
23 | console.log('********************* CALL EVENT ************************')
24 | console.log(req.body)
25 |
26 | res.send({})
27 |
28 | });
29 |
30 | router.post('/conference/events/:call_sid', function(req, res) {
31 | console.log('*********************************************************')
32 | console.log('*********************************************************')
33 | console.log('*************** CONFERENCE EVENT ************************')
34 | console.log(req.body)
35 | const client = require('twilio')(config.accountSid, config.authToken);
36 | const callerSid = req.params.call_sid
37 |
38 | // This method of putting call sid in the URI does not allow you to reconnect a calls
39 | // to customer if customer drops
40 | if (callerSid === req.body.CallSid && req.body.StatusCallbackEvent == 'participant-leave') {
41 | console.log("CALLER HUNG UP. KILL CONFERENCE")
42 | client.api.accounts(config.accountSid)
43 | .conferences(req.body.ConferenceSid)
44 | .fetch()
45 | .then((conference) => {
46 | console.log(conference)
47 | if (conference) {
48 | conference.update({status: "completed"})
49 | .then((conference) => console.log("closed conf"))
50 | }
51 | })
52 | .catch((error) => {
53 | console.log(error)
54 | })
55 | }
56 |
57 | res.send({})
58 |
59 | });
60 |
61 | router.post('/conference/:conference_sid/hold/:call_sid/:toggle', function(req, res) {
62 | const client = require('twilio')(config.accountSid, config.authToken);
63 | const confSid = req.params.conference_sid
64 | const callSid = req.params.call_sid
65 | const toggle = req.params.toggle
66 | client.api.accounts(config.accountSid)
67 | .conferences(confSid)
68 | .participants(callSid)
69 | .update({hold: toggle})
70 | .then((participant) => console.log(participant.hold))
71 | .done();
72 | });
73 |
74 | router.post('/conference/:conference_sid/terminate', function(req, res) {
75 | const client = require('twilio')(config.accountSid, config.authToken);
76 | const confSid = req.params.conference_sid
77 |
78 | console.log(confSid)
79 |
80 | client.api.accounts(config.accountSid)
81 | .conferences(confSid)
82 | .fetch()
83 | .then((conference) => {
84 | console.log(conference)
85 | if (conference) {
86 | conference.update({status: "completed"})
87 | .then((conference) => console.log("closed conf"))
88 | }
89 | })
90 | .catch((error) => {
91 | console.log(error)
92 | res.send({});
93 | })
94 | });
95 |
96 | // This endpoint dials out to a number and places that call into a conference
97 | // it also responds with Twiml to place the call accessing this endpoint into the same conference
98 | // This is called primarly when workers accept a reservation with call method
99 | router.post('/outbound/dial/:to/from/:from/conf/:conference_name', function(req, res) {
100 | console.log(req.body)
101 | const to = req.params.to
102 | const from = req.params.from
103 | const conferenceName = req.params.conference_name
104 | const client = require('twilio')(config.accountSid, config.authToken);
105 |
106 | client
107 | .conferences(conferenceName)
108 | .participants.create({to: to, from: from, earlyMedia: "true", statusCallback: "http://bcoyle.ngrok.io" + "/api/taskrouter/event"})
109 | .then((participant) => {
110 | const resp = new VoiceResponse();
111 | const dial = resp.dial();
112 | dial.conference({
113 | beep: false,
114 | waitUrl: '',
115 | startConferenceOnEnter: true,
116 | endConferenceOnExit: false
117 | }, conferenceName);
118 | console.log(resp.toString())
119 | res.send(resp.toString());
120 |
121 | // Now update the task with a conference attribute with Agent Call Sid
122 | client.taskrouter.v1
123 | .workspaces(config.workspaceSid)
124 | .tasks(conferenceName)
125 | .update({
126 | attributes: JSON.stringify({conference: {sid: participant.conferenceSid, participants: {worker: req.body.CallSid, customer: participant.callSid}}}),
127 | }).then((task) => {
128 | console.log(task)
129 | })
130 | res.send({});
131 | })
132 | .catch((error) => {
133 | console.log(error)
134 | })
135 | });
136 |
137 | router.post('/record/:conference_id/', function(req, res) {
138 | // This endpoint is set when accepting the task with the call method
139 | const conferenceSid = req.params.conference_id
140 | const client = require('twilio')(config.accountSid, config.authToken);
141 |
142 | client
143 | .conferences(conferenceSid)
144 | .participants.create({to: "+12162083661", from: "2146438999", earlyMedia: "true", record: "true"})
145 | .then((participant) => {
146 | console.log(participant.callSid)
147 | res.send({callSid: participant.callSid});
148 | })
149 | .catch((error) => {
150 | console.log(error)
151 | })
152 | });
153 |
154 | router.post('/outbound/agent', function(req, res) {
155 | // The endpoint is configure in Twiml App that Twilio client referenced when token was created
156 | const from = req.body.From
157 | const to = req.body.To
158 | const agent = req.body.Agent
159 | const client = require('twilio')(config.accountSid, config.authToken);
160 |
161 | const dial = resp.dial();
162 | dial.conference({
163 | beep: false,
164 | waitUrl: '',
165 | startConferenceOnEnter: true,
166 | endConferenceOnExit: false
167 | }, task.sid);
168 | console.log(resp.toString())
169 | res.send(resp.toString());
170 | });
171 |
172 |
173 | module.exports = router;
174 |
--------------------------------------------------------------------------------
/server/routes/chat.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | var twilio = require('twilio');
5 | var config = require('../../twilio.config');
6 |
7 |
8 | router.post('/event', function(req, res) {
9 | console.log(req.body)
10 | })
11 |
12 | router.post('/pre-event', function(req, res) {
13 | console.log(req.body)
14 |
15 | })
16 |
--------------------------------------------------------------------------------
/server/routes/sms.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | var twilioLibrary = require('twilio');
5 |
6 | //var IpMessagingClient = require('twilio').IpMessagingClient;
7 |
8 | var config = require('../../twilio.config');
9 |
10 | router.post('/', function(req, res) {
11 | var client = new twilioLibrary.Twilio(config.accountSid, config.authToken);
12 | //var client = new IpMessagingClient(config.accountSid, config.authToken);
13 | var service = client.chat.services(config.chatServiceSid)
14 |
15 | var cookie = req.cookies.convo;
16 |
17 | if (cookie) {
18 | // Part of existing conversation add this to room
19 | console.log("cookie found");
20 | service.channels(cookie).messages.create({
21 | body: req.body.body,
22 | from: req.body.from
23 | }).then(function(response) {
24 | console.log(response);
25 | }).catch(function(error) {
26 | console.log(error);
27 | });
28 | } else {
29 | service.channels.create({
30 | friendlyName: req.body.sid
31 | }).then(function(response) {
32 | console.log(response);
33 | //res.cookie('convo', response.sid, { maxAge: 300000, httpOnly: true })
34 | }).catch(function(error) {
35 | console.log(error);
36 | });
37 | }
38 | resp = new twilioLibrary.twiml.MessagingResponse();
39 | console.log(resp.toString());
40 | //var resp = client.MessagingResponse.toString();
41 | // expire in 5 mins
42 |
43 | res.send(resp.toString());
44 | });
45 |
46 |
47 |
48 | module.exports = router;
49 |
--------------------------------------------------------------------------------
/server/routes/taskrouter.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | var twilio = require('twilio');
5 | var config = require('../../twilio.config');
6 |
7 | router.post('/assignment', function(req, res) {
8 |
9 | var taskAttributes = JSON.parse(req.body.TaskAttributes);
10 | var workerAttributes = JSON.parse(req.body.WorkerAttributes);
11 | var instructions = {}
12 |
13 |
14 | //console.log("Assignment called. Ignore this for now. Accept client side");
15 | console.log(req.body)
16 |
17 | if (taskAttributes.type == "outbound_transfer") {
18 | instructions = {
19 | "instruction": "call",
20 | "accept": "true",
21 | "from": workerAttributes.phone_number,
22 | "url": config.baseUrl + "/api/calls/outbound/dial",
23 | "status_callback_url": config.baseUrl + "/api/taskrouter/event"
24 | }
25 | }
26 |
27 |
28 | });
29 |
30 | router.post('/event', function(req, res) {
31 | console.log('*********************************************************')
32 | console.log('*********************************************************')
33 | console.log('*************** TASKROUTER EVENT ************************')
34 | console.log(`${req.body.EventType} --- ${req.body.EventDescription}`)
35 | console.log(req.body)
36 |
37 | if (req.body.ResourceType == 'worker') {
38 | const client = require('twilio')(config.accountSid, config.authToken);
39 | const service = client.sync.services(config.syncServiceSid);
40 | const worker = req.body
41 |
42 | service.syncMaps('current_workers')
43 | .syncMapItems(worker.WorkerSid).update({
44 | data: {
45 | name: worker.Workername,
46 | activity: worker.WorkerActivityName,
47 | timestamp: worker.Timestamp
48 | }
49 | }).then(function(response) {
50 | console.log("worker updated");
51 | }).catch(function(error) {
52 | console.log(error);
53 | });
54 | }
55 | res.send({})
56 | })
57 |
58 |
59 | router.post('/outbound', function(req, res) {
60 | //const resp = new VoiceResponse();
61 | console.log(req.body)
62 | const from = req.body.From
63 | const to = req.body.To
64 | const agent = req.body.Agent
65 | const client = require('twilio')(config.accountSid, config.authToken);
66 | // Create a Task on a custom channel
67 | // with the Task sid that was returned place this agent leg of the call into
68 | // a conference named by the task sid
69 |
70 | client.taskrouter.v1
71 | .workspaces(config.workspaceSid)
72 | .tasks
73 | .create({
74 | workflowSid: config.workflowSid,
75 | taskChannel: 'custom1',
76 | attributes: JSON.stringify({direction:"outbound", agent_name: 'bcoyle', from: from, to: to}),
77 | }).then((task) => {
78 | console.log(task)
79 | })
80 | res.send({});
81 | });
82 |
83 | router.get('/initialize', function(req, res) {
84 | const client = require('twilio')(config.accountSid, config.authToken);
85 | const service = client.sync.services(config.syncServiceSid);
86 | service.syncMaps
87 | .create({
88 | uniqueName: 'current_workers',
89 | })
90 | .then(response => {
91 | console.log(response);
92 | client.taskrouter.v1
93 | .workspaces(config.workspaceSid)
94 | .workers
95 | .list()
96 | .then((workers) => {
97 | workers.forEach((worker) => {
98 | service.syncMaps('current_workers')
99 | .syncMapItems.create({
100 | key: worker.sid,
101 | data: {
102 | name: worker.FriendlyName,
103 | activity: worker.Activity,
104 | }
105 | }).then(function(response) {
106 | console.log(response);
107 | }).catch(function(error) {
108 | console.log(error);
109 | });
110 | });
111 | });
112 | })
113 | .catch(error => {
114 | console.log(error);
115 | });
116 | res.send({status: "ok"})
117 | })
118 |
119 |
120 |
121 | module.exports = router;
122 |
--------------------------------------------------------------------------------
/server/routes/tokens.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const _ = require('lodash');
4 |
5 | const ClientCapability = require('twilio').jwt.ClientCapability;
6 | const AccessToken = require('twilio').jwt.AccessToken;
7 | const TaskRouterCapability = require('twilio').jwt.taskrouter.TaskRouterCapability;
8 | const Policy = TaskRouterCapability.Policy;
9 | const util = require('twilio').jwt.taskrouter.util;
10 |
11 | const TASKROUTER_BASE_URL = 'https://taskrouter.twilio.com';
12 | const version = 'v1';
13 |
14 | //const SyncGrant = AccessToken.SyncGrant;
15 |
16 |
17 | const config = require('../../twilio.config');
18 |
19 | router.get('/worker/:id', function(req, res) {
20 | const worker = req.params.id;
21 |
22 | const capability = new TaskRouterCapability({
23 | accountSid: config.accountSid,
24 | authToken: config.authToken,
25 | workspaceSid: config.workspaceSid,
26 | channelId: worker,
27 | ttl: 3600}); //
28 |
29 | // Event Bridge Policies
30 | var eventBridgePolicies = util.defaultEventBridgePolicies(config.accountSid, worker);
31 |
32 | var workspacePolicies = [
33 | // Workspace fetch Policy
34 | buildWorkspacePolicy(),
35 | // Workspace Activities Update Policy
36 | buildWorkspacePolicy({ resources: ['Activities'], method: 'POST' }),
37 | buildWorkspacePolicy({ resources: ['Activities'], method: 'GET' }),
38 | //
39 | buildWorkspacePolicy({ resources: ['Tasks', '**'], method: 'POST' }),
40 | buildWorkspacePolicy({ resources: ['Tasks', '**'], method: 'GET' }),
41 | // Workspace Activities Worker Reserations Policy
42 | buildWorkspacePolicy({ resources: ['Workers', worker, 'Reservations', '**'], method: 'POST' }),
43 | buildWorkspacePolicy({ resources: ['Workers', worker, 'Reservations', '**'], method: 'GET' }),
44 | //
45 |
46 | // Workspace Activities Worker Policy
47 | buildWorkspacePolicy({ resources: ['Workers', worker], method: 'GET' }),
48 | buildWorkspacePolicy({ resources: ['Workers', worker], method: 'POST' }),
49 | ];
50 |
51 | eventBridgePolicies.concat(workspacePolicies).forEach(function (policy) {
52 | capability.addPolicy(policy);
53 | });
54 |
55 | res.send(capability.toJwt());
56 | });
57 |
58 | // Helper function to create Policy for TaskRouter token
59 | function buildWorkspacePolicy(options) {
60 | options = options || {};
61 | var resources = options.resources || [];
62 | var urlComponents = [TASKROUTER_BASE_URL, version, 'Workspaces', config.workspaceSid]
63 |
64 | return new Policy({
65 | url: urlComponents.concat(resources).join('/'),
66 | method: options.method || 'GET',
67 | allow: true
68 | });
69 | }
70 |
71 | router.get('/phone/:name', function(req, res) {
72 | const clientName = req.params.name;
73 | console.log("register phone", clientName);
74 | const capability = new ClientCapability({
75 | accountSid: config.accountSid,
76 | authToken: config.authToken,
77 | });
78 | capability.addScope(new ClientCapability.IncomingClientScope(clientName));
79 | capability.addScope(
80 | new ClientCapability.OutgoingClientScope({applicationSid: config.twimlApp})
81 | );
82 | res.send(capability.toJwt());
83 | });
84 |
85 |
86 |
87 | router.get('/chat/:name/:endpoint', function(req, res) {
88 | const clientName = req.params.name;
89 | const endpoint = req.params.endpoint;
90 | console.log("Service SID", config.chatServiceSid);
91 | const chatGrant = new AccessToken.IpMessagingGrant({
92 | serviceSid: config.chatServiceSid,
93 | endpointId: clientName + endpoint
94 | })
95 |
96 | const videoGrant = new AccessToken.VideoGrant()
97 |
98 | // Create an access token which we will sign and return to the client
99 | const accessToken = new AccessToken(
100 | config.accountSid,
101 | config.keySid,
102 | config.keySecret,
103 | '');
104 |
105 | //accessToken.identity = clientName;
106 | accessToken.identity = clientName;
107 |
108 |
109 |
110 | accessToken.addGrant(chatGrant);
111 | accessToken.addGrant(videoGrant);
112 |
113 | //return token.toJwt();
114 |
115 | // Serialize the token to a JWT string and include it in a JSON response
116 | res.send({
117 | identity: clientName,
118 | token: accessToken.toJwt()
119 | });
120 | })
121 |
122 | module.exports = router;
123 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import fetch from 'isomorphic-fetch'
2 | import urls from '../configureUrls'
3 |
4 | // Actions to register the worker
5 | function registerWorker() {
6 | return {
7 | type: 'REGISTER_WORKER',
8 | }
9 | }
10 |
11 | function errorTaskRouter(message) {
12 | return {
13 | type: 'ERROR_TASKROUTER',
14 | message: message
15 | }
16 | }
17 |
18 | function workerConnectionUpdate(status) {
19 | return {
20 | type: 'CONNECTION_UPDATED',
21 | status: status
22 | }
23 | }
24 |
25 | export function workerUpdated(worker) {
26 | return {
27 | type: 'WORKER_UPDATED',
28 | worker: worker
29 | }
30 | }
31 |
32 | export function workerClientUpdated(worker) {
33 | return {
34 | type: 'WORKER_CLIENT_UPDATED',
35 | worker: worker
36 | }
37 | }
38 |
39 | export function reservationsFetch(worker) {
40 | return ( dispatch, getState ) => {
41 | worker.fetchReservations((error, reservations) => {
42 | dispatch(reservationsUpdated(reservations.data))
43 | })
44 | }
45 | }
46 |
47 | function taskUpdated(task) {
48 | return {
49 | type: 'TASK_UPDATED',
50 | task: task
51 | }
52 | }
53 |
54 | function taskCompleted(task) {
55 | return {
56 | type: 'TASK_COMPLETED',
57 | task: task
58 | }
59 | }
60 |
61 | function reservationsUpdated(data) {
62 | return {
63 | type: 'RESERVATIONS_UPDATED',
64 | reservations: data
65 | }
66 | }
67 |
68 | export function requestTaskComplete(task) {
69 | return (dispatch) => {
70 | console.log("COMPLETE TASK")
71 | task.complete((error, task) => {
72 | if (error) {
73 | console.log(error);
74 | }
75 | dispatch(taskCompleted(task))
76 | })
77 | }
78 | }
79 |
80 | export function requestAcceptReservation() {
81 | return (dispatch, getState) => {
82 |
83 | }
84 | }
85 |
86 | // We have a generic action to refresh reservations as we
87 | // will need that after calls drop
88 | export function requestRefreshReservations() {
89 | return (dispatch, getState) => {
90 | const { taskrouter } = getState()
91 | const { worker } = taskrouter
92 | console.log(worker)
93 | taskrouter.workerClient.fetchReservations((error, reservations) => {
94 | if (error) {
95 | dispatch(errorTaskRouter("Fetching Reservations: " + error.message + " check your TaskRouter token policies"))
96 | } else {
97 | console.log(reservations.data, "RESERVATIONS")
98 | if (reservations.data.length > 0) {
99 | console.log("Your worker has reservations currently assigned to them")
100 | for (let reservation of reservations.data) {
101 | // dont display tasks arleady completed
102 | if (reservation.task.assignmentStatus != "completed") {
103 | dispatch(taskUpdated(reservation.task))
104 | }
105 | }
106 | }
107 | }
108 | })
109 | }
110 | }
111 |
112 | export function requestStateChange(newStateName) {
113 | return (dispatch, getState) => {
114 | const { taskrouter } = getState()
115 | let requestedActivitySid = getActivitySid(taskrouter.activities, newStateName)
116 | taskrouter.worker.update("ActivitySid", requestedActivitySid, (error, worker) => {
117 | if (error) {
118 | console.log(error);
119 | dispatch(errorTaskRouter("Updating Worker Activity Sid: " + error.message))
120 | } else {
121 | console.log("STATE CHANGE", worker)
122 | dispatch(workerUpdated(worker))
123 | }
124 | })
125 | }
126 | }
127 |
128 | export function requestWorker(workerSid) {
129 | return (dispatch, getState) => {
130 | dispatch(registerWorker())
131 | return fetch(urls.taskRouterToken, {
132 | method: "POST",
133 | headers: {
134 | "Content-Type": "application/x-www-form-urlencoded"
135 | },
136 | body: "workerSid="+workerSid
137 | })
138 | .then(response => response.json())
139 | .then(json => {
140 | console.log(json)
141 | // Register your TaskRouter Worker
142 | // --params token, debug, connectActivitySid, disconnectActivitySid, closeExistingSession
143 | // --see https://www.twilio.com/docs/api/taskrouter/worker-js#parameters
144 | let worker = new Twilio.TaskRouter.Worker(json.token, true, null, null, true )
145 | dispatch(workerClientUpdated(worker))
146 | console.log(worker)
147 | worker.activities.fetch((error, activityList) => {
148 | if (error) {
149 | console.log(error, "Activity Fetch Error")
150 | dispatch(errorTaskRouter("Fetching Activites: " + error.message))
151 | } else {
152 | console.log(activityList)
153 | dispatch(activitiesUpdated(activityList.data))
154 | }
155 | })
156 | worker.fetchChannels((error, channels) => {
157 | if (error) {
158 | console.log(error, "Channels Fetch Error")
159 | } else {
160 | console.log(channels)
161 | dispatch(channelsUpdated(channels.data))
162 | }
163 |
164 | })
165 |
166 | dispatch(requestRefreshReservations())
167 |
168 | worker.on("ready", (worker) => {
169 | dispatch(workerConnectionUpdate("ready"))
170 | dispatch(workerUpdated(worker))
171 | dispatch(requestPhone(worker.attributes.contact_uri.split(":").pop()))
172 | //dispatch(requestChat(worker.friendlyName))
173 | console.log("worker obj", worker)
174 | })
175 | worker.on('activity.update', (worker) => {
176 | dispatch(workerUpdated(worker))
177 | })
178 | worker.on('token.expired', () => {
179 | console.log('EXPIRED')
180 | dispatch(requestWorker(workerSid))
181 | })
182 | worker.on('error', (error) => {
183 | // You would want to provide the agent a notication of the error
184 | console.log("Websocket had an error: "+ error.response + " with message: "+error.message)
185 | console.log(error)
186 | dispatch(errorTaskRouter("Error: " + error.message))
187 | })
188 | worker.on("disconnected", function() {
189 | // You would want to provide the agent a notication of the error
190 | dispatch(workerConnectionUpdate("disconnected"))
191 | dispatch(errorTaskRouter("Web socket disconnection: " + error.message))
192 | console.log("Websocket has disconnected");
193 |
194 | })
195 | worker.on('reservation.timeout', (reservation) => {
196 | console.log("Reservation Timed Out")
197 | })
198 | // Another worker has accepted the task
199 | worker.on('reservation.rescinded', (reservation) => {
200 | console.log("Reservation Rescinded")
201 | })
202 | worker.on('reservation.rejected', (reservation) => {
203 | console.log("Reservation Rejected")
204 | })
205 | worker.on('reservation.cancelled', (reservation) => {
206 | console.log("Reservation Cancelled")
207 | })
208 | worker.on('reservation.accepted', (reservation) => {
209 | console.log("Reservation Accepted")
210 | console.log(reservation, "RESERVATION ACCEPTED RESV")
211 | dispatch(taskUpdated(reservation.task))
212 | // Phone record is a demo of stop/start recording with ghost legs
213 | //dispatch(phoneRecord(reservation.task.attributes.conference.sid))
214 | })
215 | worker.on("attributes.update", function(channel) {
216 |
217 | console.log("Worker attributes updated", channel);
218 | })
219 | worker.on("channel.availability.update", function(channel) {
220 |
221 | console.log("Channel availability updated", channel);
222 | })
223 |
224 | worker.on("channel.capacity.update", function(channel) {
225 |
226 | console.log("Channel capacity updated", channel);
227 | })
228 |
229 | worker.on('reservation.created', (reservation) => {
230 | console.log("Incoming reservation")
231 | console.log(reservation)
232 |
233 | switch (reservation.task.taskChannelUniqueName) {
234 | case 'voice':
235 | const customerLeg = reservation.task.attributes.call_sid
236 | console.log(customerLeg, "customer call sid")
237 | console.log("Create a conference for agent and customer")
238 | var options = {
239 | "ConferenceStatusCallback": urls.conferenceEvents + "?customer_sid=" + customerLeg,
240 | "ConferenceStatusCallbackEvent": "start,leave,join,end",
241 | "EndConferenceOnExit": "false",
242 | "Beep": "false"
243 | }
244 | reservation.conference(null, null, null, null, null, options)
245 | break
246 | case 'sms':
247 | reservation.accept()
248 | dispatch(chatNewRequest(reservation.task))
249 | break
250 | case 'video':
251 | reservation.accept()
252 | dispatch(videoRequest(reservation.task))
253 | break
254 | case 'custom1':
255 | const taskSid = reservation.task.sid
256 | const to = reservation.task.attributes.to
257 | const from = reservation.task.attributes.from
258 | console.log(reservation, "OUTBOUND")
259 | reservation.call(
260 | from,
261 | urls.callOutboundCallback + "?ToPhone="+to+"&FromPhone="+from+"&Sid="+taskSid,
262 | null,
263 | "true",
264 | "",
265 | "",
266 | function(error, reservation) {
267 | if (error) {
268 | console.log(error)
269 | console.log(error.message)
270 | }
271 | console.log(reservation)
272 | }
273 | )
274 |
275 | break
276 | default:
277 | reservation.reject()
278 | }
279 |
280 | })
281 | })
282 | .then(() => console.log("ih"))
283 |
284 | }
285 | }
286 |
287 | function activitiesUpdated(activities) {
288 | return {
289 | type: 'ACTIVITIES_UPDATED',
290 | activities: activities
291 | }
292 | }
293 |
294 | function channelsUpdated(channels) {
295 | return {
296 | type: 'CHANNELS_UPDATED',
297 | channels: channels
298 | }
299 | }
300 |
301 | export function dialPadUpdated(number) {
302 | return {
303 | type: 'DIAL_PAD_UPDATED',
304 | number: number
305 | }
306 | }
307 |
308 | function registerPhoneDevice() {
309 | return {
310 | type: 'REGISTER_PHONE'
311 | }
312 | }
313 |
314 | function phoneMuted(boolean) {
315 | return {
316 | type: 'PHONE_MUTED',
317 | boolean: boolean
318 | }
319 | }
320 |
321 | function phoneHeld(boolean) {
322 | return {
323 | type: 'PHONE_HELD',
324 | boolean: boolean
325 | }
326 | }
327 |
328 | function phoneWarning(warning) {
329 | return {
330 | type: 'PHONE_WARNING',
331 | warning: warning
332 | }
333 | }
334 |
335 | export function phoneDeviceUpdated(device) {
336 | return {
337 | type: 'PHONE_DEVICE_UPDATED',
338 | device: device
339 | }
340 | }
341 |
342 | export function phoneConnectionUpdated(conn) {
343 | return {
344 | type: "PHONE_CONN_UPDATED",
345 | connection: conn
346 | }
347 | }
348 |
349 | export function requestPhone(clientName) {
350 | return (dispatch, getState) => {
351 | dispatch(registerPhoneDevice())
352 | const { taskrouter } = getState()
353 | return fetch(urls.clientToken, {
354 | method: "POST",
355 | headers: {
356 | "Content-Type": "application/x-www-form-urlencoded"
357 | },
358 | body: "clientName="+clientName+"&token="+taskrouter.worker.token,
359 | })
360 | .then(response => response.json())
361 | .then(json => {
362 | Twilio.Device.setup(json.token)
363 | Twilio.Device.ready((device) => {
364 | console.log("phone is ready");
365 | dispatch(phoneDeviceUpdated(device))
366 | })
367 | Twilio.Device.incoming(function(connection) {
368 | // Accept the phone call automatically
369 | connection.accept();
370 | })
371 | Twilio.Device.connect((conn) => {
372 | console.log("incoming call")
373 | console.log(conn._direction)
374 | // Call is connected. Register callback for events to make sure UI is updated
375 | conn.mute((boolean, connection) => {
376 | dispatch(phoneMuted(boolean))
377 | })
378 | conn.disconnect((conn) => {
379 | // Phone disconnected. Refresh Reservations to capture wrapping
380 | //dispatch(requestRefreshReservations())
381 | })
382 | // Twilio Client Insights feature. Warning are received here
383 | conn.on('warning', (warning) => {
384 | dispatch(phoneWarning(warning))
385 | })
386 | // Twilio Client Insights feature. Warning are cleared here
387 | conn.on('warning-cleared', (warning) => {
388 | dispatch(phoneWarning(" "))
389 | })
390 | dispatch(phoneConnectionUpdated(conn))
391 | })
392 | Twilio.Device.disconnect((conn) => {
393 | dispatch(phoneConnectionUpdated(null))
394 | })
395 | })
396 | .then(console.log("error"))
397 | }
398 | }
399 |
400 | export function phoneHold(confSid, callSid) {
401 | return(dispatch, getState) => {
402 | const { taskrouter, phone } = getState()
403 | const newHoldState = !phone.isHeld
404 | return fetch(urls.callHold, {
405 | method: "POST",
406 | headers: {
407 | "Content-Type": "application/x-www-form-urlencoded"
408 | },
409 | body: "conference_sid="+confSid+"&call_sid="+callSid+"&toggle="+newHoldState+"&token="+taskrouter.worker.token,
410 | })
411 | .then(response => response.json())
412 | .then(json => {
413 | console.log(json)
414 | dispatch(phoneHeld(json.result))
415 | })
416 |
417 | }
418 | }
419 |
420 | export function phoneRecord(confSid, currentState) {
421 | return(dispatch, getState) => {
422 | return fetch(`/api/calls/record/${confSid}`,{method: "POST"})
423 | .then(response => response.json())
424 | .then( json => {
425 | console.log(json)
426 | dispatch(phoneRecordOn(json.callSid))
427 | })
428 | }
429 | }
430 |
431 | export function phoneRecordOn(callSid) {
432 | return {
433 | type: 'PHONE_RECORD_ON',
434 | callSid: callSid
435 | }
436 | }
437 |
438 | export function phoneRecordOff() {
439 | return {
440 | type: 'PHONE_RECORD_OFF'
441 | }
442 | }
443 |
444 | // This action is tied to the hangup phone phone button
445 | // - this action will call down to server which complete's the conference
446 | // - which then terminates all participant's calls
447 | // - after getting a response from the server this will update the task as complete
448 | export function requestConfTerminate(confSid) {
449 | return(dispatch, getState) => {
450 | const { taskrouter } = getState()
451 | return fetch(urls.conferenceTerminate, {
452 | method: "POST",
453 | headers: {
454 | "Content-Type": "application/x-www-form-urlencoded"
455 | },
456 | body: "conferenceSid="+confSid+"&token="+taskrouter.worker.token,
457 | })
458 | .then(response => response.json())
459 | .then( json => {
460 | console.log(json, "Terminate conf response")
461 | })
462 | }
463 | }
464 |
465 | // Phone Mute will use the Twilio Device to Mute the call
466 | // -- After the phone is muted a callback will fired to update
467 | // -- the redux store.
468 | export function phoneMute() {
469 | return (dispatch, getState) => {
470 | const { phone } = getState()
471 | console.log("mute clicked")
472 | console.log("Current call is muted? " + phone.currentCall.isMuted())
473 | phone.currentCall.mute(!phone.currentCall.isMuted())
474 | }
475 | }
476 |
477 | export function phoneButtonPushed(digit) {
478 | return (dispatch, getState) => {
479 | const { phone } = getState()
480 | console.log("dial pad clicked ", digit)
481 |
482 | phone.currentCall.sendDigits(digit)
483 | }
484 | }
485 |
486 | export function phoneCall() {
487 | return (dispatch, getState) => {
488 | // Call the number that is currently in the number box
489 | // pass the from and to number for the phone call as well as agent name
490 | const { phone, taskrouter } = getState()
491 | console.log("call clicked to " + phone.dialPadNumber)
492 |
493 | return fetch(urls.callOutbound,
494 | {
495 | method: "POST",
496 | headers: {
497 | 'Accept': 'application/json',
498 | 'Content-Type': 'application/x-www-form-urlencoded'
499 | },
500 | body:
501 | "To=" + phone.dialPadNumber + "&" +
502 | "From=" + taskrouter.worker.attributes.phone_number + "&" +
503 | "Agent=" + taskrouter.worker.friendlyName + "&" +
504 | "Token=" + taskrouter.worker.token
505 | })
506 | .then(response => response.json())
507 | .then( json => {
508 | console.log(json)
509 | })
510 |
511 | }
512 | }
513 |
514 | export function phoneDialCustomer(number) {
515 | return(dispatch, getState) => {
516 | return fetch(`/api/calls/confin`)
517 | .then(response => response.json())
518 | .then( json => {
519 | console.log(json)
520 | })
521 | }
522 |
523 | }
524 |
525 | export function phoneHangup() {
526 | return (dispatch, getState) => {
527 | const { phone } = getState()
528 | phone.currentCall.disconnect()
529 | }
530 | }
531 |
532 | export function chatClientUpdated(client) {
533 | return {
534 | type: 'CHAT_CLIENT_UPDATED',
535 | client: client
536 | }
537 | }
538 |
539 | export function requestChat(identity) {
540 | return (dispatch, getState) => {
541 | return fetch(`/api/tokens/chat/${identity}/browser`)
542 | .then(response => response.json())
543 | .then(json => {
544 | try {
545 | let chatClient = new Twilio.Chat.Client(json.token, {logLevel: 'debug'})
546 | dispatch(chatClientUpdated(chatClient))
547 | chatClient.on('channelJoined', (channel) => {
548 | console.log("joined chat channel")
549 | channel.on('messageAdded', (message) => {
550 | console.log("message added")
551 | })
552 | })
553 | }
554 | catch (e) {
555 | console.log(e)
556 | }
557 | })
558 | }
559 | }
560 |
561 | function chatUpdateChannel(channel) {
562 | return {
563 | type: 'CHAT_UPDATE_CHANNEL',
564 | channel: channel
565 | }
566 | }
567 |
568 | function chatAddMessage(message) {
569 | return {
570 | type: 'CHAT_ADD_MESSAGE',
571 | message: message
572 | }
573 | }
574 |
575 | export function chatNewRequest(task) {
576 | return (dispatch, getState) => {
577 | const currState = getState()
578 | console.log(currState)
579 | currState.chat.client.getChannelBySid(task.attributes.chat_channel)
580 | .then(channel => {
581 | console.log(channel)
582 | channel.on('memberJoined', (member) => {
583 | console.log("JOINED")
584 | })
585 | channel.on('messageAdded', (message) => {
586 | console.log(message)
587 | dispatch(chatAddMessage({channel: message.channel.sid, author: message.author, body: message.body}))
588 | })
589 | channel.add(currState.taskrouter.worker.friendlyName)
590 | .then(error => {
591 | console.log(error)
592 | channel.sendMessage("Brian is in the house")
593 | })
594 | .catch(error => {
595 | console.log(error)
596 | })
597 | dispatch(chatUpdateChannel(channel))
598 |
599 | })
600 |
601 | }
602 | }
603 |
604 | export function videoRequest(task) {
605 | return (dispatch, getState) => {
606 | let worker = task.workerName
607 | return fetch(`/api/tokens/chat/${worker}/browser`)
608 | .then(response => response.json())
609 | .then(json => {
610 | try {
611 | let videoClient = new Twilio.Video.connect(json.token, {name:'brian-test'})
612 | .then(room => {
613 | console.log(room, "VIDEO CREATED")
614 | let participant = room.participants.values().next().value
615 | dispatch(videoParticipantConnected(participant))
616 | room.on('participantConnected', (participant) => {
617 | console.log('A remote Participant connected: ', participant)
618 | //dispatch(videoParticipantConnected(participant))
619 | })
620 | })
621 |
622 | }
623 | catch (e) {
624 | console.log(e)
625 | }
626 | })
627 |
628 | }
629 | }
630 |
631 | function videoParticipantConnected(participant) {
632 | return {
633 | type: 'VIDEO_PARTICIPANT_CONNECTED',
634 | participant: participant
635 | }
636 | }
637 |
638 | const getActivitySid = (activities, activityName) => {
639 | let activity = activities.find((activity) =>
640 | activity.friendlyName == activityName)
641 | if (activity) {
642 | return activity.sid
643 | } else {
644 | return "no-activity-found"
645 | }
646 | }
647 |
--------------------------------------------------------------------------------
/src/components/common/Button.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | class Button extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 | this.onClick = this.onClick.bind(this);
10 | }
11 |
12 | onClick() {
13 | this.props.onClick();
14 | }
15 |
16 |
17 | render() {
18 | var classes = this.props.classes.join(" ");
19 | return (
20 | {this.props.buttonText}
21 | );
22 | }
23 | }
24 |
25 | Button.propTypes = {
26 | buttonText: React.PropTypes.string.isRequired,
27 | classes: React.PropTypes.array,
28 | disabled: React.PropTypes.bool
29 | }
30 | Button.defaultProps = {
31 | disabled: false
32 | }
33 |
34 | export default Button;
35 |
--------------------------------------------------------------------------------
/src/components/messages/MessageBox.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const MessageBox = ({messages}) => {
4 | const thread = messages.map((message) =>
5 |
6 | message.body
7 |
8 | )
9 | return (
10 |
11 | { thread }
12 |
13 | )
14 | }
15 |
16 | MessageBox.propTypes = {
17 |
18 | }
19 |
20 | MessageBox.defaultProps = { messages: [] };
21 |
22 | export default MessageBox;
23 |
--------------------------------------------------------------------------------
/src/components/messages/MessageControl.js:
--------------------------------------------------------------------------------
1 | import Button from '../common/Button.js'
2 | import React, { PropTypes } from 'react';
3 |
4 | const MessageControl = () => (
5 |
10 | )
11 |
12 | MessageControl.propTypes = {
13 |
14 | }
15 |
16 | export default MessageControl;
17 |
--------------------------------------------------------------------------------
/src/components/messages/MessageEntry.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const MessageEntry = () => (
4 |
5 |
6 |
7 | )
8 |
9 | MessageEntry.propTypes = {
10 |
11 | }
12 |
13 | export default MessageEntry;
14 |
--------------------------------------------------------------------------------
/src/components/messages/Messenger.js:
--------------------------------------------------------------------------------
1 | import MessageBox from './MessageBox';
2 | import MessageEntry from './MessageEntry';
3 | import MessageControl from './MessageControl'
4 |
5 | import React, { PropTypes } from 'react';
6 |
7 | const Messenger = ({messages}) => (
8 |
9 |
10 |
11 |
12 |
13 | )
14 |
15 | Messenger.propTypes = {
16 |
17 | }
18 |
19 | export default Messenger;
20 |
--------------------------------------------------------------------------------
/src/components/messages/MessengerContainer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Messenger from './Messenger';
4 |
5 | import { connect } from 'react-redux'
6 | import {requestChat} from '../../actions'
7 |
8 |
9 | const mapStateToProps = (state) => {
10 | const { chat } = state
11 |
12 | return {
13 | messages: chat.messages,
14 | }
15 | }
16 |
17 | const MessageBoxContainer = connect(mapStateToProps)(Messenger)
18 |
19 | export default MessageBoxContainer
20 |
--------------------------------------------------------------------------------
/src/components/phone/CallControl.js:
--------------------------------------------------------------------------------
1 | import Button from '../common/Button.js';
2 | import React, { PropTypes } from 'react';
3 |
4 | const CallControl = ({
5 | status,
6 | mute,
7 | hangup,
8 | call,
9 | hold,
10 | record,
11 | isMuted,
12 | isHeld,
13 | isRecording,
14 | recordingCallSid,
15 | task
16 | }) => {
17 | let buttons, callSid, confSid
18 | // Make sure the conference attributes are there
19 |
20 | if (task && typeof task.attributes.conference != "undefined") {
21 | callSid = task.attributes.conference.participants.customer
22 | confSid = task.attributes.conference.sid
23 | }
24 | // if status is open then we are on a call
25 | if (status == "open") {
26 | buttons =
27 |
28 | hangup(task, confSid)} classes={["hangup"]} buttonText="Hangup" />
29 |
30 | hold(confSid, callSid)} classes={["hold"]} buttonText={isHeld ? 'UnHold' : 'Hold' } />
31 | record(confSid, recordingCallSid)} classes={["hold"]} buttonText={isRecording ? "Pause" : "Record"} />
32 | hold(confSid, callSid)} classes={["hold"]} buttonText="Transfer" />
33 |
34 | } else {
35 | buttons = call()} classes={["call"]} buttonText="Call" />
36 | }
37 | return (
38 |
43 | )
44 | }
45 |
46 | CallControl.propTypes = {
47 |
48 | }
49 |
50 | export default CallControl;
51 |
--------------------------------------------------------------------------------
/src/components/phone/KeyPad.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const KeyPad = ({buttonPress}) => {
4 | let numbers = [
5 | '1', '2', '3',
6 | '4', '5', '6',
7 | '7', '8', '9',
8 | '*', '0', '#']
9 |
10 | return (
11 |
12 |
13 | {numbers.map(function(item, i) {
14 | return (
15 |
buttonPress(item)} key={i} value={item}> {item}
16 | );
17 | })}
18 |
19 |
20 | )
21 | }
22 |
23 | KeyPad.propTypes = {
24 | buttonPress: React.PropTypes.func.isRequired
25 | }
26 |
27 | export default KeyPad
28 |
--------------------------------------------------------------------------------
/src/components/phone/NumberEntry.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const NumberEntry = ({entry}) => (
4 |
5 | entry(e.target.value)}>
6 |
7 | )
8 |
9 | NumberEntry.propTypes = {
10 | entry: React.PropTypes.func.isRequired
11 | }
12 |
13 | export default NumberEntry;
14 |
--------------------------------------------------------------------------------
/src/components/phone/PhoneContainer.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import Phone from './phone';
5 | import {phoneMute, phoneHangup, phoneButtonPushed, phoneHold, phoneCall, dialPadUpdated, requestTaskComplete, requestConfTerminate} from '../../actions'
6 | import { connect } from 'react-redux'
7 |
8 |
9 | const mapStateToProps = (state) => {
10 | const { phone, taskrouter } = state
11 | let conf = ""
12 | let caller = ""
13 | if (taskrouter.conference) {
14 | conf = taskrouter.conference.sid
15 | caller = taskrouter.conference.participants.customer
16 | }
17 | const task = taskrouter.tasks[0]
18 |
19 | return {
20 | status: phone.currentCall._status,
21 | isMuted: phone.isMuted,
22 | isHeld: phone.isHeld,
23 | isRecording: phone.isRecording,
24 | recordingCallSid: phone.recordingLegSid,
25 | task: task,
26 | warning: phone.warning
27 | }
28 | }
29 |
30 | const mapDispatchToProps = (dispatch) => {
31 | return {
32 | onMuteClick: () => {
33 | dispatch(phoneMute())
34 | },
35 | onHangupClick: (reservation, confSid) => {
36 | console.log(reservation)
37 | dispatch(phoneHangup())
38 | //dispatch(requestTaskComplete(reservation))
39 | dispatch(requestConfTerminate(confSid))
40 | },
41 | onHoldClick: (confSid, callSid) => {
42 | dispatch(phoneHold(confSid, callSid))
43 | },
44 | onRecordClick: (confSid, callSid) => {
45 | dispatch(phoneHold(confSid, callSid))
46 | },
47 | onCallClick: () => {
48 | dispatch(phoneCall())
49 | },
50 | onNumberEntryChange: (number) => {
51 | dispatch(dialPadUpdated(number))
52 | },
53 | onKeyPadNumberClick: (key) => {
54 | dispatch(phoneButtonPushed(key))
55 | }
56 | }
57 | }
58 |
59 |
60 | const PhoneContainer = connect(
61 | mapStateToProps,
62 | mapDispatchToProps
63 | )(Phone)
64 |
65 | export default PhoneContainer
66 |
--------------------------------------------------------------------------------
/src/components/phone/phone.js:
--------------------------------------------------------------------------------
1 | import NumberEntry from './NumberEntry';
2 | import KeyPad from './KeyPad';
3 | import CallControl from './CallControl';
4 |
5 | import React, { PropTypes } from 'react';
6 |
7 | const Phone = ({status, onMuteClick, onKeyPadNumberClick, onNumberEntryChange, onHangupClick, onCallClick, onHoldClick, onRecordClick, isMuted, isHeld, isRecording, recordingCallSid, task}) => (
8 |
9 |
10 |
11 |
12 |
13 | )
14 |
15 | Phone.propTypes = {
16 |
17 | }
18 |
19 | export default Phone;
20 |
--------------------------------------------------------------------------------
/src/components/taskrouter/QueueStats.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | import StatBox from './StatBox';
4 |
5 | const QueueStats = ({}) => (
6 |
7 |
8 |
9 |
10 | )
11 |
12 | QueueStats.PropTypes = {
13 |
14 | }
15 |
16 | export default QueueStats;
17 |
--------------------------------------------------------------------------------
/src/components/taskrouter/ReservationControls.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const ReservationControls = ({ onRequestAccept, onRequestDecline}) => (
4 |
5 | onRequestAccept()} classes={["agent-status", "ready"]} buttonText="Accept" disabled={available}/>
6 | onRequestDecline()} classes={["agent-status", "not-ready"]} buttonText="Reject" disabled={!available}/>
7 |
8 | )
9 |
10 | ReservationControls.propTypes = {
11 |
12 | }
13 |
14 |
15 | export default ReservationControls;
16 |
--------------------------------------------------------------------------------
/src/components/taskrouter/ReservationControlsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux'
2 | import {requestStateChange} from '../../actions'
3 | import SimpleAgentStatusControls from './SimpleAgentStatusControls'
4 |
5 |
6 | const mapStateToProps = (state) => {
7 | const { taskrouter } = state
8 | return {
9 | available: taskrouter.worker.available,
10 | status: taskrouter.worker.activityName,
11 | }
12 | }
13 |
14 | const mapDispatchToProps = (dispatch) => {
15 | return {
16 | onRequestAccept: (reservation) => {
17 | dispatch(requestAcceptReservation(reservation))
18 | },
19 | onRequestDecline: (reservation) => {
20 | dispatch(requestDeclineReservation(reservation))
21 | }
22 | }
23 | }
24 |
25 |
26 | const ReservationControlsContainer = connect(
27 | mapStateToProps,
28 | mapDispatchToProps
29 | )(ReservationControls)
30 |
31 | export default ReservationControlsContainer
32 |
--------------------------------------------------------------------------------
/src/components/taskrouter/SimpleAgentStatusControls.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import Button from '../common/Button';
3 | import TaskControls from './TaskControls'
4 |
5 | const SimpleAgentStatusControls = ({ available, status, onRequestChange, onRequestComplete, tasks, warning }) => {
6 | const currTasks = tasks.map((task) =>
7 |
8 | )
9 |
10 | return (
11 |
12 |
{ status }
13 |
{ warning }
14 |
onRequestChange("Idle")} classes={["agent-status", "ready"]} buttonText="Ready" disabled={available}/>
15 | onRequestChange("Offline")} classes={["agent-status", "not-ready"]} buttonText="Not Ready" disabled={!available}/>
16 | { currTasks }
17 |
18 | )
19 | }
20 |
21 | SimpleAgentStatusControls.propTypes = {
22 | available: PropTypes.bool.isRequired,
23 | status: PropTypes.string,
24 | onRequestChange: PropTypes.func.isRequired
25 | }
26 |
27 | SimpleAgentStatusControls.defaultProps = {
28 | available: false,
29 | }
30 |
31 |
32 | export default SimpleAgentStatusControls;
33 |
--------------------------------------------------------------------------------
/src/components/taskrouter/SimpleAgentStatusControlsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux'
2 | import {requestStateChange, requestTaskComplete} from '../../actions'
3 | import SimpleAgentStatusControls from './SimpleAgentStatusControls'
4 |
5 |
6 | const mapStateToProps = (state) => {
7 | const { taskrouter, phone } = state
8 | return {
9 | available: taskrouter.worker.available,
10 | status: taskrouter.worker.activityName,
11 | tasks: taskrouter.tasks,
12 | warning: phone.warning
13 | }
14 | }
15 |
16 | const mapDispatchToProps = (dispatch) => {
17 | return {
18 | onRequestChange: (newStateName) => {
19 | dispatch(requestStateChange(newStateName))
20 | },
21 | onRequestComplete: (task) => {
22 | dispatch(requestTaskComplete(task))
23 | }
24 |
25 | }
26 | }
27 |
28 |
29 | const SimpleAgentStatusControlsContainer = connect(
30 | mapStateToProps,
31 | mapDispatchToProps
32 | )(SimpleAgentStatusControls)
33 |
34 | export default SimpleAgentStatusControlsContainer
35 |
--------------------------------------------------------------------------------
/src/components/taskrouter/StatBox.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const StatBox = ({statName, statValue}) => {
4 | let lowerCaseStat = statName.toLowerCase()
5 | let statusClass = lowerCaseStat + '-status'
6 | let numClass = lowerCaseStat + '-num'
7 |
8 | return (
9 |
10 |
-
11 | {statName}
12 |
13 | )
14 | }
15 |
16 | StatBox.propTypes = {
17 | statName: PropTypes.string.isRequired,
18 | statValue: PropTypes.string
19 | }
20 |
21 | StatBox.defaultProps = {
22 | statName: "Agents",
23 | statValue: "Queues"
24 | }
25 | export default StatBox;
26 |
--------------------------------------------------------------------------------
/src/components/taskrouter/TaskControls.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const TaskControls = ({task, onRequestComplete}) => (
4 |
9 | )
10 |
11 | TaskControls.propTypes = {
12 |
13 | }
14 |
15 |
16 | export default TaskControls;
17 |
--------------------------------------------------------------------------------
/src/components/workspace/AgentWorkSpace.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import SimpleAgentStatusControlsContainer from '../taskrouter/SimpleAgentStatusControlsContainer';
3 | import MessengerContainer from '../messages/MessengerContainer';
4 | import PhoneContainer from '../phone/PhoneContainer'
5 | import QueueStats from '../taskrouter/QueueStats';
6 | //import VideoDisplay from '../video/Video'
7 | //import VideoPlayer from '../video/VideoPlayer'
8 |
9 | const AgentWorkSpace = ({channels = [], currInteraction, participant = {}, error, errorMessage }) => {
10 | let component = null
11 |
12 | switch (currInteraction) {
13 | case 'video':
14 | component =
15 | break;
16 | case 'voice':
17 | component =
18 | break
19 | case 'chat':
20 | component =
21 | break
22 | default:
23 | component =
24 | }
25 | if (error) {
26 | return Something went wrong. {errorMessage}.
27 | }
28 | return (
29 |
30 |
31 |
32 | {component}
33 |
34 |
35 | )
36 | }
37 |
38 | AgentWorkSpace.propTypes = {
39 |
40 | }
41 |
42 | export default AgentWorkSpace
43 |
--------------------------------------------------------------------------------
/src/components/workspace/AgentWorkSpaceContainer.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import { connect } from 'react-redux'
3 | import {requestWorker} from '../../actions'
4 |
5 | import AgentWorkSpace from './AgentWorkSpace'
6 |
7 |
8 | class AgentWorkSpaceContainer extends Component {
9 |
10 | constructor(props) {
11 | super(props);
12 | }
13 |
14 | componentDidMount() {
15 | // When this top level component loads get the worker sid from the URL
16 | const { dispatch } = this.props
17 | var url = new URL(window.location.href)
18 | dispatch(requestWorker(url.searchParams.get("worker")))
19 | }
20 |
21 | componentWillUnmount() {
22 | console.log("UNMOUNTING")
23 | }
24 |
25 |
26 | render() {
27 | const { channels, participant, error, errorMessage } = this.props
28 | let current = "default"
29 |
30 | return (
31 |
32 | );
33 | }
34 | }
35 |
36 | const mapStateToProps = (state) => {
37 | return {
38 | channels: state.taskrouter.channels,
39 | participant: state.chat.videoParticipant,
40 | error: state.taskrouter.error,
41 | errorMessage: state.taskrouter.errorMessage
42 | }
43 | }
44 |
45 |
46 | export default connect(mapStateToProps)(AgentWorkSpaceContainer)
47 |
--------------------------------------------------------------------------------
/src/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux'
2 | import thunkMiddleware from 'redux-thunk'
3 | import createLogger from 'redux-logger'
4 | import taskRouterApp from './reducers'
5 |
6 | const loggerMiddleware = createLogger()
7 |
8 | export default function configureStore(preloadedState) {
9 | return createStore(
10 | taskRouterApp,
11 | preloadedState,
12 | applyMiddleware(
13 | thunkMiddleware,
14 | loggerMiddleware
15 | )
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/configureUrls_SAMPLE.js:
--------------------------------------------------------------------------------
1 | let baseUrl = 'https://YOUR DOMAIN/'
2 |
3 | module.exports = {
4 | baseUrl: baseUrl,
5 | taskRouterToken: baseUrl + 'taskrouter-client-token',
6 | clientToken: baseUrl + 'twilio-client-token',
7 | conferenceTerminate: baseUrl + 'terminate-conference',
8 | conferenceEvents: baseUrl + 'conference-event',
9 | callHold: baseUrl + 'hold-call',
10 | callOutbound: baseUrl + 'outbound',
11 | callOutboundCallback: baseUrl + 'outbound-call-callback',
12 | }
13 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Twilio Starter
6 |
7 |
8 |
9 |
10 |
11 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill'
2 |
3 | import React from 'react'
4 | import { render } from 'react-dom'
5 | import { Provider } from 'react-redux'
6 | import configureStore from './configureStore'
7 | import taskRouterApp from './reducers'
8 | import AgentWorkSpaceContainer from './components/workspace/AgentWorkSpaceContainer'
9 |
10 | require('./styles/Reset.css');
11 | require('./styles/App.css');
12 |
13 | let store = configureStore()
14 |
15 | render(
16 |
17 |
18 | ,
19 | document.getElementById('app')
20 | )
21 |
--------------------------------------------------------------------------------
/src/reducers/chat.js:
--------------------------------------------------------------------------------
1 | const chat = (state = {
2 | client: false,
3 | isRegistered: false,
4 | currentChannel: [],
5 | messages:[],
6 | videoParticipant: null
7 | }, action) => {
8 | switch (action.type) {
9 | case 'REGISTER_CHAT':
10 | return Object.assign({}, state, {
11 | isRegistered: false
12 | });
13 | case 'CHAT_CLIENT_UPDATED':
14 | return Object.assign({}, state, {
15 | isRegistered: true,
16 | client: action.client
17 | });
18 | case 'CHAT_ADD_MESSAGE':
19 | return Object.assign({}, state, {
20 | messages: [
21 | ...state.messages,
22 | action.message
23 | ]
24 | })
25 | case 'CHAT_UPDATE_CHANNEL':
26 | return Object.assign({}, state, {
27 | currentChannel: action.channel
28 | });
29 | case 'VIDEO_PARTICIPANT_CONNECTED':
30 | console.log(action.participant, "reducer partc")
31 | return Object.assign({}, state, {
32 | videoParticipant: action.participant
33 | });
34 | default:
35 | return state;
36 | }
37 | }
38 |
39 | export default chat;
40 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import taskrouter from './taskrouter'
3 | import phone from './phone'
4 | import chat from './chat'
5 |
6 | const taskRouterApp = combineReducers({
7 | taskrouter,
8 | phone,
9 | chat
10 | });
11 |
12 | export default taskRouterApp
13 |
--------------------------------------------------------------------------------
/src/reducers/phone.js:
--------------------------------------------------------------------------------
1 | const phone = (state = {
2 | currentCall: false,
3 | device: {},
4 | isMuted: false,
5 | isRecording: false,
6 | isHeld: false,
7 | recordingLegSid: "",
8 | warning: "",
9 | isRegistered: false,
10 | dialPadNumber: "",
11 | }, action) => {
12 | switch (action.type) {
13 | case 'REGISTER_PHONE':
14 | return Object.assign({}, state, {
15 | isRegistered: false
16 | });
17 | case 'PHONE_DEVICE_UPDATED':
18 | return Object.assign({}, state, {
19 | isRegistered: true,
20 | device: action.device
21 | });
22 | case 'DIAL_PAD_UPDATED':
23 | return Object.assign({}, state, {
24 | dialPadNumber: action.number
25 | });
26 | case 'PHONE_CONN_UPDATED':
27 | return Object.assign({}, state, {
28 | currentCall: action.connection || false
29 | });
30 | case 'PHONE_MUTED':
31 | return Object.assign({}, state, {
32 | isMuted: action.boolean
33 | });
34 | case 'PHONE_HELD':
35 | return Object.assign({}, state, {
36 | isHeld: action.boolean
37 | });
38 | case 'PHONE_RECORD_ON':
39 | return Object.assign({}, state, {
40 | isRecording: true,
41 | recordingLegSid: action.callSid
42 | });
43 | case 'PHONE_RECORD_OFF':
44 | return Object.assign({}, state, {
45 | isRecording: false
46 | });
47 | case 'PHONE_WARNING':
48 | return Object.assign({}, state, {
49 | warning: action.warning
50 | });
51 | default:
52 | return state;
53 | }
54 | }
55 |
56 | export default phone;
57 |
--------------------------------------------------------------------------------
/src/reducers/taskrouter.js:
--------------------------------------------------------------------------------
1 |
2 | const taskrouter = (state = {
3 | isRegistering: false,
4 | connectionStatus: "disconnected",
5 | workerClient: {},
6 | worker: {},
7 | activities: [],
8 | channels: [],
9 | tasks: [],
10 | error: false,
11 | errorMessage: ""
12 | }, action) => {
13 | switch (action.type) {
14 | case 'CONNECTION_UPDATED':
15 | return Object.assign({}, state, {
16 | connectionStatus: action.status
17 | });
18 | case 'ERROR_TASKROUTER':
19 | return Object.assign({}, state, {
20 | error: true,
21 | errorMessage: action.message
22 | });
23 | case 'REGISTER_WORKER':
24 | return Object.assign({}, state, {
25 | isRegistering: true
26 | });
27 | case 'WORKER_UPDATED':
28 | return Object.assign({}, state, {
29 | isRegistering: false,
30 | worker: action.worker
31 | });
32 | case 'WORKER_CLIENT_UPDATED':
33 | return Object.assign({}, state, {
34 | workerClient: action.worker
35 | });
36 | case 'ACTIVITIES_UPDATED':
37 | return Object.assign({}, state, {
38 | activities: action.activities
39 | });
40 | case 'CHANNELS_UPDATED':
41 | return Object.assign({}, state, {
42 | channels: action.channels
43 | });
44 | case 'RESERVATIONS_UPDATED':
45 | return Object.assign({}, state, {
46 | reservations: action.reservations
47 | });
48 | case 'TASK_UPDATED':
49 | return Object.assign({}, state, {
50 | tasks: [
51 | ...state.tasks,
52 | action.task
53 | ],
54 | });
55 | case 'TASK_COMPLETED':
56 | return Object.assign({}, state, {
57 | tasks: state.tasks.filter(task => task.sid !== action.task.sid)
58 | });
59 | default:
60 | return state;
61 | }
62 | }
63 |
64 | export default taskrouter;
65 |
--------------------------------------------------------------------------------
/src/styles/App.css:
--------------------------------------------------------------------------------
1 | .clearfix:before, .clearfix:after { content: " "; display: table; }
2 | .clearfix:after { clear: both; }
3 | .clearfix { *zoom: 1; }
4 |
5 | *, *:before, *:after {
6 | -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;
7 | -webkit-touch-callout: none;
8 | -webkit-user-select: none;
9 | -khtml-user-select: none;
10 | -moz-user-select: none;
11 | -ms-user-select: none;
12 | user-select: none;
13 | }
14 |
15 | body {
16 | font-family: "Helvetica", Arial, sans-serif;
17 | background-color: white;
18 | }
19 |
20 | #softphone {
21 | width: 200px;
22 | margin: 10px auto 0px;
23 | }
24 |
25 | #agent-status-controls {
26 | margin: 10px 0 20px;
27 | position: relative;
28 | }
29 |
30 | .agent-status {
31 | border: none;
32 | padding: 6px 10px;
33 | background-image: linear-gradient(bottom, #ddd 20%, #eee 72%);
34 | background-image: -o-linear-gradient(bottom, #ddd 20%, #eee 72%);
35 | background-image: -moz-linear-gradient(bottom, #ddd 20%, #eee 72%);
36 | background-image: -webkit-linear-gradient(bottom, #ddd 20%, #eee 72%);
37 | background-image: -ms-linear-gradient(bottom, #ddd 20%, #eee 72%);
38 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #ddd), color-stop(0.72, #eee));
39 | color: #333;
40 | text-shadow: 0px -1px 0px rgba(255, 255, 255, 0.3);
41 | box-shadow: inset 0px 0px 1px rgba(0, 0, 0, 0.4);
42 | cursor: pointer;
43 | text-align: center;
44 | }
45 |
46 | button.agent-status {
47 | display: inline-block;
48 | float: left;
49 | width: 50%;
50 | margin: 0;
51 | -webkit-appearance: none;
52 | -moz-appearance: none;
53 | appearance: none;
54 | }
55 |
56 | @-webkit-keyframes pulse {
57 | 0% {background-color: #EA6045;}
58 | 50% {background-color: #e54a23;}
59 | 100% {background-color: #EA6045;}
60 | }
61 |
62 | div.agent-status {
63 | position: absolute;
64 | top: 0;
65 | left: 0;
66 | width: 100%;
67 | height: 100%;
68 | z-index: 1000;
69 | font-size: 12px;
70 | line-height: 12px;
71 | background-image: none;
72 | background-color: #EA6045;
73 | -webkit-animation: pulse 1s infinite alternate;
74 | color: #fff;
75 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.2);
76 | border-radius: 2px;
77 | }
78 |
79 | .agent-status:active, .agent-status:focus {
80 | outline: none;
81 | }
82 |
83 | .agent-status[disabled] {
84 | box-shadow: inset 0px 0px 15px rgba(0, 0, 0, 0.6);
85 | opacity: 0.8;
86 | text-shadow: 0px 1px 0px rgba(0, 0, 0, 0.4);
87 | }
88 |
89 | .agent-status.ready {
90 | border-radius: 2px 0 0 2px;
91 | }
92 |
93 | .agent-status.ready[disabled] {
94 | background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
95 | background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
96 | background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
97 | background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
98 | background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
99 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
100 | color: #f5f5f5;
101 | }
102 |
103 | .agent-status.not-ready {
104 | border-radius: 0 2px 2px 0;
105 | }
106 |
107 | .agent-status.not-ready[disabled] {
108 | background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
109 | background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
110 | background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
111 | background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
112 | background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
113 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
114 | color: #f5f5f5;
115 | }
116 |
117 | #dialer {
118 | border: solid 1px #ddd;
119 | border-width: 0 0 0 1px;
120 | -webkit-transition: opacity 1s;
121 | transition: opacity 1s;
122 | }
123 |
124 | input {
125 | border: solid 1px #ddd;
126 | border-bottom-color: #d5d5d5;
127 | border-radius: 2px 2px 0 0;
128 | font-size: 16px;
129 | width: 100%;
130 | padding: 14px 5px;
131 | display: block;
132 | text-align: center;
133 | margin: 0;
134 | position: relative;
135 | z-index: 100;
136 | -webkit-transition: border-color 1s;
137 | transition: border-color 1s;
138 | }
139 |
140 | #number-entry {
141 | position: relative;
142 | height: 48px;
143 | }
144 |
145 | .incoming input {
146 | border: solid 1px red;
147 | }
148 |
149 | .incoming #dialer {
150 | opacity: 0.25;
151 | }
152 |
153 | .softphone .incoming-call-status {
154 | position: absolute;
155 | display: none;
156 | top: 100%;
157 | left: 0;
158 | right: 0;
159 | background: red;
160 | color: #fff;
161 | font-size: 16px;
162 | padding: 6px 0;
163 | text-align: center;
164 | width: 100%;
165 | z-index: 200;
166 | border-radius: 0 0 2px 2px;
167 | opacity: 0;
168 | -webkit-transition: opacity 1s;
169 | transition: opacity 1s;
170 | }
171 |
172 | .incoming .incoming-call-status {
173 | display: block;
174 | opacity: 1;
175 | }
176 |
177 | .number {
178 | color: #555;
179 | font-weight: 300;
180 | cursor: pointer;
181 | display: inline-block;
182 | height: 38px;
183 | line-height: 38px;
184 | font-size: 21px;
185 | width: 33.333333333%;
186 | background-image: linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
187 | background-image: -o-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
188 | background-image: -moz-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
189 | background-image: -webkit-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
190 | background-image: -ms-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
191 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e9e9e9), color-stop(0.72, #e5e5e5));
192 | text-shadow: 0px 1px 0px #f5f5f5;
193 | filter: dropshadow(color=#f5f5f5, offx=0, offy=1);
194 | text-align: center;
195 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
196 | inset -1px 0px 0px rgba(0, 0, 0, 0.1),
197 | inset 0px 1px 0px #f5f5f5,
198 | inset 0 -1px 0px #d6d6d6;
199 | }
200 |
201 | .number.ast {
202 | font-size: 33px;
203 | line-height: 32px;
204 | vertical-align: -1px;
205 | }
206 |
207 | .number:hover {
208 | background-image: linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
209 | background-image: -o-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
210 | background-image: -moz-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
211 | background-image: -webkit-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
212 | background-image: -ms-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
213 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #f5f5f5), color-stop(0.72, #f0f0f0));
214 | }
215 |
216 | .number:active {
217 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
218 | inset -1px 0px 0px rgba(0, 0, 0, 0.1),
219 | inset 0px 1px 0px #f5f5f5,
220 | inset 0 -1px 0px #d6d6d6,
221 | inset 0px 0px 5px 2px rgba(0, 0, 0, 0.15);
222 | }
223 |
224 | #action-buttons button {
225 | -webkit-appearance: none;
226 | -moz-appearance: none;
227 | appearance: none;
228 | display: inline-block;
229 | border: none;
230 | margin: 0;
231 | cursor: pointer;
232 | }
233 |
234 | #action-buttons .call, #action-buttons .send {
235 | color: #f5f5f5;
236 | width: 100%;
237 | font-size: 18px;
238 | padding: 8px 0;
239 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.3);
240 | margin: 0;
241 | background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
242 | background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
243 | background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
244 | background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
245 | background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
246 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
247 | border-radius: 0 0 2px 2px;
248 | }
249 |
250 | #action-buttons .answer, #action-buttons .hangup, #action-buttons .wrapup {
251 | color: #f5f5f5;
252 | width: 100%;
253 | font-size: 18px;
254 | padding: 8px 0;
255 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.4);
256 | margin: 0;
257 | background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
258 | background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
259 | background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
260 | background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
261 | background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
262 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
263 | border-radius: 0 0 2px 2px;
264 | }
265 |
266 | #action-buttons .hold, #action-buttons .unhold, #action-buttons .mute {
267 | color: #444;
268 | width: 50%;
269 | font-size: 14px;
270 | padding: 12px 0;
271 | text-shadow: 0px 1px 0px rgba(255, 255, 255, 0.3);
272 | margin: 0;
273 | background-image: linear-gradient(bottom, #bbb 20%, #ccc 72%);
274 | background-image: -o-linear-gradient(bottom, #bbb 20%, #ccc 72%);
275 | background-image: -moz-linear-gradient(bottom, #bbb 20%, #ccc 72%);
276 | background-image: -webkit-linear-gradient(bottom, #bbb 20%, #ccc 72%);
277 | background-image: -ms-linear-gradient(bottom, #bbb 20%, #ccc 72%);
278 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #bbb), color-stop(0.72, #ccc));
279 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
280 | inset -1px 0px 0px rgba(0, 0, 0, 0.1);
281 | }
282 |
283 | .mute {
284 | border-radius: 0 0 0 2px;
285 | }
286 |
287 | .hold, .unhold {
288 | border-radius: 0 2px 0 0;
289 | }
290 |
291 | #team-status .agents-status, #team-status .queues-status {
292 | display: inline-block;
293 | width: 50%;
294 | margin: 0;
295 | font-size: 14px;
296 | text-align: center;
297 | padding: 12px 0 16px;
298 | border-bottom: solid 1px #e5e5e5;
299 | }
300 |
301 | #team-status [class*="num"] {
302 | font-size: 32px;
303 | font-weight: bold;
304 | margin-bottom: 6px;
305 | }
306 |
307 | #call-data {
308 | display: none;
309 | }
310 |
311 | .powered-by {
312 | text-align: right;
313 | padding: 10px 0;
314 | }
315 |
316 | img {
317 | width: 100px;
318 | }
319 |
320 | /* SMS Stuff */
321 | .messages {
322 |
323 | }
324 |
325 | .messages-container {
326 | padding-bottom: 20px;
327 | }
328 |
329 | .message-entry {
330 | text-align: left;
331 | }
332 |
333 | .messagecardthread-inbound {
334 | background-color: #fff;
335 | border-left: 5px solid #37a805;
336 | border-bottom: 1px solid #96e375;
337 | -moz-box-shadow: 0px 3px 6px 0px #DEDEDE;
338 | -webkit-box-shadow: 0px 3px 6px 0px #DEDEDE;
339 | box-shadow: 0px 3px 6px 0px #DEDEDE;
340 | border-radius: 10px;
341 | padding: 10px 12px 12px 12px;
342 | margin: 10px 80px 0px 0px;
343 | }
344 |
345 | .messagecardthread-outbound {
346 | background-color: #d6f8f7;
347 | border-right: 5px solid #008bbc;
348 | border-bottom: 1px solid #7cc4e6;
349 | -moz-box-shadow: 0px 3px 6px 0px #DEDEDE;
350 | -webkit-box-shadow: 0px 3px 6px 0px #DEDEDE;
351 | box-shadow: 0px 3px 6px 0px #DEDEDE;
352 | border-radius: 10px;
353 | padding: 10px 12px 12px 12px;
354 | margin: 10px 30px 0px 80px;
355 | }
356 |
--------------------------------------------------------------------------------
/src/styles/Reset.css:
--------------------------------------------------------------------------------
1 | html, body, div, span, object, iframe,
2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
3 | abbr, address, cite, code,
4 | del, dfn, em, img, ins, kbd, q, samp,
5 | small, strong, sub, sup, var,
6 | b, i,
7 | dl, dt, dd, ol, ul, li,
8 | fieldset, form, label, legend,
9 | table, caption, tbody, tfoot, thead, tr, th, td,
10 | article, aside, canvas, details, figcaption, figure,
11 | footer, header, hgroup, menu, nav, section, summary,
12 | time, mark, audio, video {
13 | margin:0;
14 | padding:0;
15 | border:0;
16 | outline:0;
17 | font-size:100%;
18 | vertical-align:baseline;
19 | background:transparent;
20 | }
21 |
22 | body {
23 | line-height:1;
24 | }
25 |
26 | article,aside,details,figcaption,figure,
27 | footer,header,hgroup,menu,nav,section {
28 | display:block;
29 | }
30 |
31 | nav ul {
32 | list-style:none;
33 | }
34 |
35 | blockquote, q {
36 | quotes:none;
37 | }
38 |
39 | blockquote:before, blockquote:after,
40 | q:before, q:after {
41 | content:'';
42 | content:none;
43 | }
44 |
45 | a {
46 | margin:0;
47 | padding:0;
48 | font-size:100%;
49 | vertical-align:baseline;
50 | background:transparent;
51 | }
52 |
53 | /* change colours to suit your needs */
54 | ins {
55 | background-color:#ff9;
56 | color:#000;
57 | text-decoration:none;
58 | }
59 |
60 | /* change colours to suit your needs */
61 | mark {
62 | background-color:#ff9;
63 | color:#000;
64 | font-style:italic;
65 | font-weight:bold;
66 | }
67 |
68 | del {
69 | text-decoration: line-through;
70 | }
71 |
72 | abbr[title], dfn[title] {
73 | border-bottom:1px dotted;
74 | cursor:help;
75 | }
76 |
77 | table {
78 | border-collapse:collapse;
79 | border-spacing:0;
80 | }
81 |
82 | /* change border colour to suit your needs */
83 | hr {
84 | display:block;
85 | height:1px;
86 | border:0;
87 | border-top:1px solid #cccccc;
88 | margin:1em 0;
89 | padding:0;
90 | }
91 |
92 | input, select {
93 | vertical-align:middle;
94 | }
--------------------------------------------------------------------------------
/styles/App.css:
--------------------------------------------------------------------------------
1 | .clearfix:before, .clearfix:after { content: " "; display: table; }
2 | .clearfix:after { clear: both; }
3 | .clearfix { *zoom: 1; }
4 |
5 | *, *:before, *:after {
6 | -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;
7 | -webkit-touch-callout: none;
8 | -webkit-user-select: none;
9 | -khtml-user-select: none;
10 | -moz-user-select: none;
11 | -ms-user-select: none;
12 | user-select: none;
13 | }
14 |
15 | body {
16 | font-family: "Helvetica", Arial, sans-serif;
17 | background-color: white;
18 | }
19 |
20 | #softphone {
21 | width: 200px;
22 | margin: 10px auto 0px;
23 | }
24 |
25 | #agent-status-controls {
26 | margin: 10px 0 20px;
27 | position: relative;
28 | }
29 |
30 | .agent-status {
31 | border: none;
32 | padding: 6px 10px;
33 | background-image: linear-gradient(bottom, #ddd 20%, #eee 72%);
34 | background-image: -o-linear-gradient(bottom, #ddd 20%, #eee 72%);
35 | background-image: -moz-linear-gradient(bottom, #ddd 20%, #eee 72%);
36 | background-image: -webkit-linear-gradient(bottom, #ddd 20%, #eee 72%);
37 | background-image: -ms-linear-gradient(bottom, #ddd 20%, #eee 72%);
38 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #ddd), color-stop(0.72, #eee));
39 | color: #333;
40 | text-shadow: 0px -1px 0px rgba(255, 255, 255, 0.3);
41 | box-shadow: inset 0px 0px 1px rgba(0, 0, 0, 0.4);
42 | cursor: pointer;
43 | text-align: center;
44 | }
45 |
46 | button.agent-status {
47 | display: inline-block;
48 | float: left;
49 | width: 50%;
50 | margin: 0;
51 | -webkit-appearance: none;
52 | -moz-appearance: none;
53 | appearance: none;
54 | }
55 |
56 | @-webkit-keyframes pulse {
57 | 0% {background-color: #EA6045;}
58 | 50% {background-color: #e54a23;}
59 | 100% {background-color: #EA6045;}
60 | }
61 |
62 | div.agent-status {
63 | position: absolute;
64 | top: 0;
65 | left: 0;
66 | width: 100%;
67 | height: 100%;
68 | z-index: 1000;
69 | font-size: 12px;
70 | line-height: 12px;
71 | background-image: none;
72 | background-color: #EA6045;
73 | -webkit-animation: pulse 1s infinite alternate;
74 | color: #fff;
75 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.2);
76 | border-radius: 2px;
77 | }
78 |
79 | .agent-status:active, .agent-status:focus {
80 | outline: none;
81 | }
82 |
83 | .agent-status[disabled] {
84 | box-shadow: inset 0px 0px 15px rgba(0, 0, 0, 0.6);
85 | opacity: 0.8;
86 | text-shadow: 0px 1px 0px rgba(0, 0, 0, 0.4);
87 | }
88 |
89 | .agent-status.ready {
90 | border-radius: 2px 0 0 2px;
91 | }
92 |
93 | .agent-status.ready[disabled] {
94 | background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
95 | background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
96 | background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
97 | background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
98 | background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
99 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
100 | color: #f5f5f5;
101 | }
102 |
103 | .agent-status.not-ready {
104 | border-radius: 0 2px 2px 0;
105 | }
106 |
107 | .agent-status.not-ready[disabled] {
108 | background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
109 | background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
110 | background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
111 | background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
112 | background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
113 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
114 | color: #f5f5f5;
115 | }
116 |
117 | #dialer {
118 | border: solid 1px #ddd;
119 | border-width: 0 0 0 1px;
120 | -webkit-transition: opacity 1s;
121 | transition: opacity 1s;
122 | }
123 |
124 | input {
125 | border: solid 1px #ddd;
126 | border-bottom-color: #d5d5d5;
127 | border-radius: 2px 2px 0 0;
128 | font-size: 16px;
129 | width: 100%;
130 | padding: 14px 5px;
131 | display: block;
132 | text-align: center;
133 | margin: 0;
134 | position: relative;
135 | z-index: 100;
136 | -webkit-transition: border-color 1s;
137 | transition: border-color 1s;
138 | }
139 |
140 | #number-entry {
141 | position: relative;
142 | height: 48px;
143 | }
144 |
145 | .incoming input {
146 | border: solid 1px red;
147 | }
148 |
149 | .incoming #dialer {
150 | opacity: 0.25;
151 | }
152 |
153 | .softphone .incoming-call-status {
154 | position: absolute;
155 | display: none;
156 | top: 100%;
157 | left: 0;
158 | right: 0;
159 | background: red;
160 | color: #fff;
161 | font-size: 16px;
162 | padding: 6px 0;
163 | text-align: center;
164 | width: 100%;
165 | z-index: 200;
166 | border-radius: 0 0 2px 2px;
167 | opacity: 0;
168 | -webkit-transition: opacity 1s;
169 | transition: opacity 1s;
170 | }
171 |
172 | .incoming .incoming-call-status {
173 | display: block;
174 | opacity: 1;
175 | }
176 |
177 | .number {
178 | color: #555;
179 | font-weight: 300;
180 | cursor: pointer;
181 | display: inline-block;
182 | height: 38px;
183 | line-height: 38px;
184 | font-size: 21px;
185 | width: 33.333333333%;
186 | background-image: linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
187 | background-image: -o-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
188 | background-image: -moz-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
189 | background-image: -webkit-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
190 | background-image: -ms-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
191 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e9e9e9), color-stop(0.72, #e5e5e5));
192 | text-shadow: 0px 1px 0px #f5f5f5;
193 | filter: dropshadow(color=#f5f5f5, offx=0, offy=1);
194 | text-align: center;
195 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
196 | inset -1px 0px 0px rgba(0, 0, 0, 0.1),
197 | inset 0px 1px 0px #f5f5f5,
198 | inset 0 -1px 0px #d6d6d6;
199 | }
200 |
201 | .number.ast {
202 | font-size: 33px;
203 | line-height: 32px;
204 | vertical-align: -1px;
205 | }
206 |
207 | .number:hover {
208 | background-image: linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
209 | background-image: -o-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
210 | background-image: -moz-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
211 | background-image: -webkit-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
212 | background-image: -ms-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
213 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #f5f5f5), color-stop(0.72, #f0f0f0));
214 | }
215 |
216 | .number:active {
217 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
218 | inset -1px 0px 0px rgba(0, 0, 0, 0.1),
219 | inset 0px 1px 0px #f5f5f5,
220 | inset 0 -1px 0px #d6d6d6,
221 | inset 0px 0px 5px 2px rgba(0, 0, 0, 0.15);
222 | }
223 |
224 | #action-buttons button {
225 | -webkit-appearance: none;
226 | -moz-appearance: none;
227 | appearance: none;
228 | display: inline-block;
229 | border: none;
230 | margin: 0;
231 | cursor: pointer;
232 | }
233 |
234 | #action-buttons .call, #action-buttons .send {
235 | color: #f5f5f5;
236 | width: 100%;
237 | font-size: 18px;
238 | padding: 8px 0;
239 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.3);
240 | margin: 0;
241 | background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
242 | background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
243 | background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
244 | background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
245 | background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
246 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
247 | border-radius: 0 0 2px 2px;
248 | }
249 |
250 | #action-buttons .answer, #action-buttons .hangup, #action-buttons .wrapup {
251 | color: #f5f5f5;
252 | width: 100%;
253 | font-size: 18px;
254 | padding: 8px 0;
255 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.4);
256 | margin: 0;
257 | background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
258 | background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
259 | background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
260 | background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
261 | background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
262 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
263 | border-radius: 0 0 2px 2px;
264 | }
265 |
266 | #action-buttons .hold, #action-buttons .unhold, #action-buttons .mute {
267 | color: #444;
268 | width: 50%;
269 | font-size: 14px;
270 | padding: 12px 0;
271 | text-shadow: 0px 1px 0px rgba(255, 255, 255, 0.3);
272 | margin: 0;
273 | background-image: linear-gradient(bottom, #bbb 20%, #ccc 72%);
274 | background-image: -o-linear-gradient(bottom, #bbb 20%, #ccc 72%);
275 | background-image: -moz-linear-gradient(bottom, #bbb 20%, #ccc 72%);
276 | background-image: -webkit-linear-gradient(bottom, #bbb 20%, #ccc 72%);
277 | background-image: -ms-linear-gradient(bottom, #bbb 20%, #ccc 72%);
278 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #bbb), color-stop(0.72, #ccc));
279 | box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
280 | inset -1px 0px 0px rgba(0, 0, 0, 0.1);
281 | }
282 |
283 | .mute {
284 | border-radius: 0 0 0 2px;
285 | }
286 |
287 | .hold, .unhold {
288 | border-radius: 0 2px 0 0;
289 | }
290 |
291 | #team-status .agents-status, #team-status .queues-status {
292 | display: inline-block;
293 | width: 50%;
294 | margin: 0;
295 | font-size: 14px;
296 | text-align: center;
297 | padding: 12px 0 16px;
298 | border-bottom: solid 1px #e5e5e5;
299 | }
300 |
301 | #team-status [class*="num"] {
302 | font-size: 32px;
303 | font-weight: bold;
304 | margin-bottom: 6px;
305 | }
306 |
307 | #call-data {
308 | display: none;
309 | }
310 |
311 | .powered-by {
312 | text-align: right;
313 | padding: 10px 0;
314 | }
315 |
316 | img {
317 | width: 100px;
318 | }
319 |
320 | /* SMS Stuff */
321 | .messages {
322 |
323 | }
324 |
325 | .messages-container {
326 | padding-bottom: 20px;
327 | }
328 |
329 | .message-entry {
330 | text-align: left;
331 | }
332 |
333 | .messagecardthread-inbound {
334 | background-color: #fff;
335 | border-left: 5px solid #37a805;
336 | border-bottom: 1px solid #96e375;
337 | -moz-box-shadow: 0px 3px 6px 0px #DEDEDE;
338 | -webkit-box-shadow: 0px 3px 6px 0px #DEDEDE;
339 | box-shadow: 0px 3px 6px 0px #DEDEDE;
340 | border-radius: 10px;
341 | padding: 10px 12px 12px 12px;
342 | margin: 10px 80px 0px 0px;
343 | }
344 |
345 | .messagecardthread-outbound {
346 | background-color: #d6f8f7;
347 | border-right: 5px solid #008bbc;
348 | border-bottom: 1px solid #7cc4e6;
349 | -moz-box-shadow: 0px 3px 6px 0px #DEDEDE;
350 | -webkit-box-shadow: 0px 3px 6px 0px #DEDEDE;
351 | box-shadow: 0px 3px 6px 0px #DEDEDE;
352 | border-radius: 10px;
353 | padding: 10px 12px 12px 12px;
354 | margin: 10px 30px 0px 80px;
355 | }
356 |
--------------------------------------------------------------------------------
/styles/Reset.css:
--------------------------------------------------------------------------------
1 | html, body, div, span, object, iframe,
2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
3 | abbr, address, cite, code,
4 | del, dfn, em, img, ins, kbd, q, samp,
5 | small, strong, sub, sup, var,
6 | b, i,
7 | dl, dt, dd, ol, ul, li,
8 | fieldset, form, label, legend,
9 | table, caption, tbody, tfoot, thead, tr, th, td,
10 | article, aside, canvas, details, figcaption, figure,
11 | footer, header, hgroup, menu, nav, section, summary,
12 | time, mark, audio, video {
13 | margin:0;
14 | padding:0;
15 | border:0;
16 | outline:0;
17 | font-size:100%;
18 | vertical-align:baseline;
19 | background:transparent;
20 | }
21 |
22 | body {
23 | line-height:1;
24 | }
25 |
26 | article,aside,details,figcaption,figure,
27 | footer,header,hgroup,menu,nav,section {
28 | display:block;
29 | }
30 |
31 | nav ul {
32 | list-style:none;
33 | }
34 |
35 | blockquote, q {
36 | quotes:none;
37 | }
38 |
39 | blockquote:before, blockquote:after,
40 | q:before, q:after {
41 | content:'';
42 | content:none;
43 | }
44 |
45 | a {
46 | margin:0;
47 | padding:0;
48 | font-size:100%;
49 | vertical-align:baseline;
50 | background:transparent;
51 | }
52 |
53 | /* change colours to suit your needs */
54 | ins {
55 | background-color:#ff9;
56 | color:#000;
57 | text-decoration:none;
58 | }
59 |
60 | /* change colours to suit your needs */
61 | mark {
62 | background-color:#ff9;
63 | color:#000;
64 | font-style:italic;
65 | font-weight:bold;
66 | }
67 |
68 | del {
69 | text-decoration: line-through;
70 | }
71 |
72 | abbr[title], dfn[title] {
73 | border-bottom:1px dotted;
74 | cursor:help;
75 | }
76 |
77 | table {
78 | border-collapse:collapse;
79 | border-spacing:0;
80 | }
81 |
82 | /* change border colour to suit your needs */
83 | hr {
84 | display:block;
85 | height:1px;
86 | border:0;
87 | border-top:1px solid #cccccc;
88 | margin:1em 0;
89 | padding:0;
90 | }
91 |
92 | input, select {
93 | vertical-align:middle;
94 | }
--------------------------------------------------------------------------------
/twilio.config.js:
--------------------------------------------------------------------------------
1 | // Twilio config stored in environment variables
2 | var config = {};
3 |
4 | config.accountSid = process.env.TWILIO_ACCOUNT_SID;
5 | config.authToken = process.env.TWILIO_AUTH_TOKEN;
6 | config.twimlApp = process.env.TWILIO_TWIML_APP_SID;
7 | config.workspaceSid = process.env.TWILIO_WORKSPACE_SID;
8 | config.workflowSid = process.env.TWILIO_WORKFLOW_SID;
9 | config.chatServiceSid = process.env.TWILIO_CHAT_SERVICE_SID;
10 | config.keySid = process.env.TWILIO_API_KEY;
11 | config.keySecret = process.env.TWILIO_API_SECRET;
12 | config.syncServiceSid = process.env.TWILIO_SYNC_SERVICE_SID;
13 | config.syncKey = process.env.TWILIO_SYNC_KEY;
14 | config.syncSecret = process.env.TWILIO_SYNC_SECRET;
15 |
16 | config.baseUrl = process.env.BASE_URL;
17 |
18 | module.exports = config;
19 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var webpack = require('webpack');
3 |
4 | var port = 8080;
5 | var publicPath = '/assets/';
6 |
7 |
8 | module.exports = {
9 | entry: './src/main.js',
10 | output: {
11 | path: path.resolve(__dirname, 'build/assets'),
12 | publicPath: "/assets/",
13 | filename: 'app.js',
14 | },
15 | devServer: {
16 | contentBase: "build/",
17 | disableHostCheck: true,
18 | proxy: {
19 | "/api/*": {
20 | target: "http://localhost:3000",
21 | secure: false
22 | }
23 | }
24 | },
25 | module: {
26 | loaders: [
27 | {
28 | test: /.js?$/,
29 | loader: 'babel-loader',
30 | exclude: /node_modules/,
31 | query: {
32 | presets: ['es2015', 'react']
33 | }
34 | },
35 | {
36 | test: /\.css$/,
37 | loader: 'style!css'
38 | }
39 | ]
40 | },
41 | };
42 |
--------------------------------------------------------------------------------