@defra/flood-webchat 0.0.1-alpha.3 → 0.0.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@defra/flood-webchat",
3
- "version": "0.0.1-alpha.3",
3
+ "version": "0.0.1-alpha.4",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
7
7
  "browser": "src/client/index.js",
8
8
  "scripts": {
9
9
  "test": "npm run lint && npm run unit-test",
10
- "dev": "(cd demo && webpack server)",
10
+ "dev": "npm run compile-templates && node -r dotenv/config node_modules/.bin/webpack server --config demo/webpack.config.mjs",
11
11
  "unit-test": "jest",
12
12
  "lint": "npm run lint:js && npm run lint:scss",
13
13
  "lint:fix": "npm run lint:js -- --fix && npm run lint:scss -- --fix",
14
14
  "lint:js": "standard",
15
15
  "lint:scss": "stylelint \"**/*.scss\"",
16
- "prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') { throw e } }\""
16
+ "postinstall": "npm run compile-templates",
17
+ "prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') { throw e } }\"",
18
+ "compile-templates": "mkdir -p dist && nunjucks-precompile templates > ./dist/templates.js"
17
19
  },
18
20
  "standard": {
19
21
  "ignore": [
@@ -34,21 +36,28 @@
34
36
  "babel-loader": "^9.1.3",
35
37
  "core-js": "^2.6.11",
36
38
  "css-loader": "^6.8.1",
39
+ "dotenv": "^16.3.1",
37
40
  "husky": "^8.0.3",
38
41
  "jest": "^29.6.3",
39
42
  "jest-environment-jsdom": "^29.6.3",
40
43
  "mini-css-extract-plugin": "^2.7.6",
44
+ "node-cache": "^5.1.2",
41
45
  "node-sass": "^9.0.0",
42
46
  "sass": "^1.66.1",
43
47
  "sass-loader": "^13.3.2",
44
48
  "standard": "^17.1.0",
45
49
  "stylelint": "^15.10.3",
46
- "stylelint-config-standard-scss": "^10.0.0",
50
+ "stylelint-config-gds": "^1.0.0",
47
51
  "webpack": "^5.88.2",
48
52
  "webpack-cli": "^5.1.4",
49
53
  "webpack-dev-server": "^4.15.1"
50
54
  },
51
55
  "peerDependencies": {
52
56
  "govuk-frontend": "^3.14.0"
57
+ },
58
+ "dependencies": {
59
+ "@nice-devone/nice-cxone-chat-web-sdk": "1.3.0",
60
+ "axios": "^0.24.0",
61
+ "nunjucks": "^3.2.4"
53
62
  }
54
63
  }
@@ -1,3 +1,13 @@
1
- export function init (value) {
2
- return value === true
1
+ import WebChat from './lib/webchat.js'
2
+
3
+ /**
4
+ * @param {string} id
5
+ * @param {object} options
6
+ * @param {string} options.brandId
7
+ * @param {string} options.channelId
8
+ * @param {string} options.environmentName
9
+ * @param {string} options.availabilityEndpoint
10
+ **/
11
+ export function init (id, options) {
12
+ return new WebChat(id, options)
3
13
  }
@@ -0,0 +1,75 @@
1
+ import nunjucks from './nunjucks.js'
2
+
3
+ class Availability {
4
+ constructor (id, openChatCb) {
5
+ // Set availability container
6
+ this.container = document.getElementById(id)
7
+
8
+ // Events
9
+ document.addEventListener('click', e => {
10
+ if (e.target.hasAttribute('data-wc-open-btn')) {
11
+ console.log('click', e.target.id)
12
+ openChatCb(e, e.target.id)
13
+ }
14
+ })
15
+ document.addEventListener('keydown', e => {
16
+ if (e.key === ' ' && e.target.hasAttribute('data-wc-open-btn')) {
17
+ e.preventDefault()
18
+ }
19
+ })
20
+ document.addEventListener('keyup', e => {
21
+ if (e.key === ' ' && e.target.hasAttribute('data-wc-open-btn')) {
22
+ openChatCb(e, e.target.id)
23
+ }
24
+ })
25
+ }
26
+
27
+ update (state) {
28
+ console.log('availability.update()')
29
+
30
+ const container = this.container
31
+ const isText = !container.hasAttribute('data-wc-no-text')
32
+ const hasThread = state.hasThread
33
+ const isLink = hasThread || (isText && state.availability === 'AVAILABLE')
34
+ const btnText = hasThread ? 'Show chat' : 'Start chat'
35
+
36
+ // Update container
37
+ container.innerHTML = nunjucks.render('availability.html', {
38
+ model: {
39
+ isText,
40
+ isLink,
41
+ availability: state.availability,
42
+ btnText,
43
+ unseen: state.unseen
44
+ }
45
+ })
46
+
47
+ // Conditionally reinstate focus after dom replacement
48
+ const link = container.querySelector('[data-wc-open-btn]')
49
+ const hasFocus = document.activeElement.hasAttribute('data-wc-open-btn')
50
+ if (hasFocus && link) {
51
+ link.focus()
52
+ }
53
+ }
54
+
55
+ scroll (state) {
56
+ const container = this.container
57
+ const link = container.querySelector('[data-wc-link]')
58
+ if (!link) {
59
+ return
60
+ }
61
+
62
+ // Calculate offset
63
+ const rect = container.getBoundingClientRect()
64
+ const isBelowFold = rect.top + 35 > (window.innerHeight || document.documentElement.clientHeight)
65
+
66
+ // Toggle static/sticky display
67
+ const isHidden = (state.view === 'OPEN' || state.view === 'END') && !state.isOpen
68
+ const isFixed = isHidden && isBelowFold
69
+ document.documentElement.classList.toggle('wc-scroll-padding', isFixed)
70
+ document.body.classList.toggle('wc-scroll-padding', isFixed)
71
+ link.classList.toggle('wc-link--fixed', isFixed)
72
+ }
73
+ }
74
+
75
+ export default Availability
@@ -0,0 +1,17 @@
1
+ class Config {
2
+ static _breakpoints = { // Max width
3
+ mobile: '640px',
4
+ tablet: '834px'
5
+ }
6
+
7
+ static maxQueue = 2
8
+ static timeout = -1 // 20 minutes
9
+ static countdown = -1 // Seconds
10
+ static poll = -1 // Seconds
11
+
12
+ static getBreakpoint (device) {
13
+ return this._breakpoints[device]
14
+ }
15
+ }
16
+
17
+ export default Config
@@ -0,0 +1,149 @@
1
+ import Utils from './utils.js'
2
+
3
+ class Keyboard {
4
+ static _isKeyboard = false
5
+
6
+ static init (state) {
7
+ this._state = state
8
+ this._detectFocus()
9
+
10
+ Utils.listenForDevice('mobile', this._updateInert.bind(this))
11
+ }
12
+
13
+ static _detectFocus () {
14
+ document.addEventListener('keydown', e => {
15
+ this._isKeyboard = true
16
+ if (e.key === 'Tab' || e.key === 'Escape' || e.key === 'Esc') {
17
+ this._constrainFocus(e)
18
+ }
19
+ })
20
+
21
+ document.addEventListener('keyup', e => {
22
+ this._isKeyboard = true
23
+ if (e.key === 'Tab') {
24
+ const el = this._focusParent()
25
+ if (el) {
26
+ this._toggleInert(el)
27
+ }
28
+ }
29
+ })
30
+
31
+ document.addEventListener('pointerdown', e => {
32
+ this._isKeyboard = false
33
+ })
34
+
35
+ document.addEventListener('focus', e => {
36
+ const isScope = !!e.target.closest('#wc-availability, #wc-panel')
37
+ if (!isScope) {
38
+ return
39
+ }
40
+ const target = e.target.hasAttribute('data-wc-focus-parent') ? e.target.parentNode : e.target
41
+ target.classList.toggle('wc-focus-visible', this._isKeyboard)
42
+ }, true)
43
+
44
+ document.addEventListener('blur', e => {
45
+ const target = e.target.hasAttribute('data-wc-focus-parent') ? e.target.parentNode : e.target
46
+ target.classList.remove('wc-focus-visible')
47
+ }, true)
48
+ }
49
+
50
+ static _isFocusable (el) {
51
+ const tagNames = ['A', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT']
52
+ const isHidden = el.hidden || el.style.display === 'none'
53
+ const isTextbox = el.getAttribute('role') === 'textbox'
54
+
55
+ return (tagNames.includes(el.tagName) || el.tabIndex >= 0 || isTextbox) && !isHidden
56
+ }
57
+
58
+ static _focusParent () {
59
+ const el = document.querySelector('[data-wc]')
60
+
61
+ if (el && !document.activeElement.closest('[data-wc]')) {
62
+ el.focus()
63
+ }
64
+
65
+ return el
66
+ }
67
+
68
+ static _constrainFocus (e) {
69
+ const el = this._focusParent()
70
+ if (!el) {
71
+ return
72
+ }
73
+
74
+ const focusableEls = this._getFocusableEls(el)
75
+ const firstFocusableEl = focusableEls[0]
76
+ const lastFocusableEl = focusableEls[focusableEls.length - 1]
77
+
78
+ if (e.shiftKey) {
79
+ if (document.activeElement === firstFocusableEl) {
80
+ lastFocusableEl.focus()
81
+ e.preventDefault()
82
+ }
83
+ } else {
84
+ if (document.activeElement === lastFocusableEl) {
85
+ firstFocusableEl.focus()
86
+ e.preventDefault()
87
+ }
88
+ }
89
+ }
90
+
91
+ static _toggleInert (el) {
92
+ const inert = document.querySelectorAll('[data-wc-inert]')
93
+ for (let i = 0; i < inert.length; i++) {
94
+ const el = inert[i]
95
+ el.removeAttribute('aria-hidden')
96
+ el.removeAttribute('data-wc-inert')
97
+ }
98
+ if (el) {
99
+ while (el.parentNode && el !== document.body) {
100
+ let sibling = el.parentNode.firstChild
101
+ while (sibling) {
102
+ if (sibling.nodeType === 1 && sibling !== el) {
103
+ if (!sibling.hasAttribute('aria-hidden')) {
104
+ sibling.setAttribute('aria-hidden', true)
105
+ sibling.setAttribute('data-wc-inert', '')
106
+ }
107
+ }
108
+ sibling = sibling.nextSibling
109
+ }
110
+ el = el.parentNode
111
+ }
112
+ }
113
+ }
114
+
115
+ static _updateInert () {
116
+ const isOpen = this._state.isOpen
117
+ const activeElement = document.activeElement
118
+ const isWithinDialog = activeElement && !!document.activeElement.closest('[data-wc][role="dialog"][open]')
119
+ const containerEl = document.querySelector('[data-wc]')
120
+ if (!isWithinDialog) {
121
+ this._toggleInert(isOpen ? containerEl : null)
122
+ }
123
+ }
124
+
125
+ static _getFocusableEls (el) {
126
+ const selectors = [
127
+ 'a[href]:not([disabled])',
128
+ 'button:not([disabled])',
129
+ 'textarea:not([disabled])',
130
+ 'input:not([disabled])',
131
+ 'select:not([disabled])',
132
+ '*[tabindex="0"]:not([disabled])'
133
+ ]
134
+ let focusableEls = Array.from(el.querySelectorAll(selectors.join(',')))
135
+ focusableEls = focusableEls.filter(e => !e.closest('[hidden]') && !e.closest('[aria-hidden="true"]'))
136
+ return focusableEls
137
+ }
138
+
139
+ static toggleInert (el) {
140
+ this._toggleInert(el)
141
+ }
142
+
143
+ static getFirstFocusableEl () {
144
+ const focusableEls = this._getFocusableEls(document)
145
+ return focusableEls.length ? focusableEls[0] : null
146
+ }
147
+ }
148
+
149
+ export default Keyboard
@@ -0,0 +1,62 @@
1
+ class Notification {
2
+ constructor () {
3
+ const url = '/public/sounds/notification.mp3'
4
+ this._url = url
5
+
6
+ // Create context
7
+ const context = new (window.AudioContext || window.webkitAudioContext)()
8
+ // context.addEventListener('statechange', () => {
9
+ // console.log('Audio: ', context.state)
10
+ // })
11
+ this._context = context
12
+
13
+ // Unlock audio
14
+ this._unlockAudioContext()
15
+
16
+ // Load buffer
17
+ this._init(url)
18
+ }
19
+
20
+ _init () {
21
+ const context = this._context
22
+ const url = this._url
23
+
24
+ fetch(url)
25
+ .then(response => response.arrayBuffer())
26
+ .then(buffer => context.decodeAudioData(buffer))
27
+ .then(decodedData => {
28
+ this._buffer = decodedData
29
+ })
30
+ .catch(console.error)
31
+ }
32
+
33
+ _unlockAudioContext () {
34
+ const context = this._context
35
+ if (context.state === 'suspended') {
36
+ const events = ['touchstart', 'touchend', 'mousedown', 'wheel', 'keydown', 'click']
37
+ const unlock = e => {
38
+ events.forEach(event => {
39
+ document.body.removeEventListener(event, unlock)
40
+ })
41
+ context.resume()
42
+ }
43
+ events.forEach(event => {
44
+ document.body.addEventListener(event, unlock, false)
45
+ })
46
+ }
47
+ }
48
+
49
+ playSound () {
50
+ const context = this._context
51
+ const buffer = this._buffer
52
+ console.log('Play sound (context state: ', context.state, ')')
53
+
54
+ // Create source
55
+ const source = context.createBufferSource()
56
+ source.buffer = buffer
57
+ source.connect(context.destination)
58
+ source.start()
59
+ }
60
+ }
61
+
62
+ export default Notification
@@ -0,0 +1,3 @@
1
+ import '../../../dist/templates.js'
2
+ import nunjucks from 'nunjucks/browser/nunjucks-slim.js'
3
+ export default nunjucks
@@ -0,0 +1,293 @@
1
+ import { Button, CharacterCount } from 'govuk-frontend'
2
+ import Utils from './utils.js'
3
+ import Config from './config.js'
4
+ import Keyboard from './keyboard.js'
5
+ import nunjucks from './nunjucks.js'
6
+
7
+ class Panel {
8
+ constructor () {
9
+ this._container = null
10
+ this._timeout = Utils.getDuration(Config.timeout)
11
+ }
12
+
13
+ _initComponents (el) {
14
+ // Initialise GOV.UK components
15
+ const buttons = el.querySelectorAll('[data-module="govuk-button"]')
16
+ for (const button of buttons) {
17
+ new Button(button).init()
18
+ }
19
+ const characterCounts = el.querySelectorAll('[data-module="govuk-character-count"]')
20
+ for (const characterCount of characterCounts) {
21
+ new CharacterCount(characterCount).init()
22
+ }
23
+ // Initialise autoresize
24
+ const textbox = el.querySelector('[data-wc-textbox]')
25
+ if (textbox) {
26
+ Utils.autosize(textbox)
27
+ }
28
+ }
29
+
30
+ _updateMessagesLabel (total) {
31
+ const body = this.body
32
+ const label = `Conversation with ${total} message${total > 1 ? 's' : ''}`
33
+ body.setAttribute('aria-label', label)
34
+ }
35
+
36
+ _scrollToLatest (isScroll) {
37
+ if (!isScroll) {
38
+ return
39
+ }
40
+
41
+ // Scroll to latest
42
+ this.body.scrollTop = this.body.scrollHeight
43
+ }
44
+
45
+ create (state, addEvents) {
46
+ console.log('panel.create()')
47
+
48
+ const model = {
49
+ ...state
50
+ }
51
+ const html = nunjucks.render('panel.html', { model })
52
+ document.body.insertAdjacentHTML('beforeend', html)
53
+
54
+ const container = document.querySelector('[data-wc]')
55
+ const header = container.querySelector('[data-wc-header]')
56
+ const body = container.querySelector('[data-wc-body]')
57
+ const footer = container.querySelector('[data-wc-footer]')
58
+ this.container = container
59
+ this.header = header
60
+ this.body = body
61
+ this.footer = footer
62
+
63
+ // Move focus to container and set inert
64
+ container.focus()
65
+ Keyboard.toggleInert(container)
66
+
67
+ Utils.listenForDevice('mobile', this.setAttributes.bind(this, state))
68
+
69
+ // Message events
70
+ container.addEventListener('keyup', e => {
71
+ if (e.target.hasAttribute('data-wc-textbox')) {
72
+ const textbox = e.target
73
+ const label = textbox.previousElementSibling
74
+ // Conditionally show label
75
+ Utils.toggleLabel(label, e.key, textbox)
76
+ // Conditionally submit form
77
+ Utils.submit(e, textbox)
78
+ }
79
+ })
80
+ container.addEventListener('keydown', e => {
81
+ if (e.target.hasAttribute('data-wc-textbox')) {
82
+ const textbox = e.target
83
+ const label = textbox.previousElementSibling
84
+ // Conditionally hide label
85
+ Utils.toggleLabel(label, e.key, textbox)
86
+ // Conditionally suppress enter
87
+ Utils.suppressEnter(e, textbox)
88
+ }
89
+ })
90
+ container.addEventListener('change', e => {
91
+ if (e.target.hasAttribute('data-wc-textbox')) {
92
+ console.log('change', e.target)
93
+ const textbox = e.target
94
+ const label = textbox.previousElementSibling
95
+ Utils.toggleLabel(label, null, textbox)
96
+ }
97
+ }, true)
98
+
99
+ // Add panel events
100
+ addEvents()
101
+ }
102
+
103
+ update (state, error) {
104
+ console.log('panel.updateAll()')
105
+
106
+ const container = document.getElementById('wc-panel')
107
+ if (!container) {
108
+ return
109
+ }
110
+
111
+ // Update labelledyby
112
+ this.container.setAttribute('aria-labelledyby', error ? 'wc-title wc-subtitle wc-error' : 'wc-title wc-subtitle')
113
+
114
+ this.updateHeader(state)
115
+ this.updateBody(state, error)
116
+ this.updateFooter(state)
117
+ }
118
+
119
+ updateHeader (state) {
120
+ console.log('panel.updateHeader()')
121
+
122
+ const container = document.getElementById('wc-panel')
123
+ if (!container) {
124
+ return
125
+ }
126
+
127
+ // Model
128
+ const model = {
129
+ ...state
130
+ }
131
+
132
+ const header = this.header
133
+ header.innerHTML = nunjucks.render('header.html', { model })
134
+
135
+ this._initComponents(header)
136
+ }
137
+
138
+ updateBody (state, error) {
139
+ console.log('panel.updateBody()')
140
+
141
+ const container = document.getElementById('wc-panel')
142
+ if (!container) {
143
+ return
144
+ }
145
+
146
+ // Model
147
+ const model = {
148
+ ...state,
149
+ error,
150
+ timeout: this._timeout
151
+ }
152
+
153
+ const body = this.body
154
+ body.innerHTML = nunjucks.render('body.html', { model })
155
+
156
+ // Toggle body keyboard accessibility
157
+ const isViewOpen = state.view === 'OPEN'
158
+ if (isViewOpen) {
159
+ body.tabIndex = 0
160
+ const total = state.messages.length
161
+ this._updateMessagesLabel(total)
162
+ this._scrollToLatest(state.isScroll)
163
+ } else {
164
+ body.removeAttribute('tabindex')
165
+ body.removeAttribute('aria-label')
166
+ }
167
+
168
+ this._initComponents(body)
169
+ }
170
+
171
+ updateFooter (state) {
172
+ console.log('panel.updateFooter()')
173
+
174
+ const container = document.getElementById('wc-panel')
175
+ if (!container) {
176
+ return
177
+ }
178
+
179
+ // Model
180
+ const model = {
181
+ ...state
182
+ }
183
+
184
+ const footer = this.footer
185
+ footer.innerHTML = nunjucks.render('footer.html', { model })
186
+
187
+ this._initComponents(footer)
188
+ }
189
+
190
+ addMessage (state, message) {
191
+ const list = this.container.querySelector('[data-wc-list]')
192
+ if (!list) {
193
+ return
194
+ }
195
+
196
+ list.insertAdjacentHTML('beforeend', message.html)
197
+
198
+ // Update message label
199
+ const total = state.messages.length
200
+ this._updateMessagesLabel(total)
201
+
202
+ // Scroll messages
203
+ this._scrollToLatest(state.isScroll)
204
+ }
205
+
206
+ toggleAgentTyping (state, isTyping) {
207
+ const list = this.body.querySelector('[data-wc-list]')
208
+ if (!list) {
209
+ return
210
+ }
211
+
212
+ // Add or remove elements from list
213
+ const el = list.querySelector('[data-wc-agent-typing]')
214
+ if (isTyping) {
215
+ list.insertAdjacentHTML('beforeend', nunjucks.render('agent-typing.html', {
216
+ model: {
217
+ name: state.assignee
218
+ }
219
+ }))
220
+
221
+ // Scroll to show new elements
222
+ this._scrollToLatest(state.isScroll)
223
+ } else if (el) {
224
+ el.remove()
225
+ }
226
+ }
227
+
228
+ toggleTimeout (state, hasTimeout) {
229
+ const container = this.container
230
+ const timeout = container.querySelector('[data-wc-timeout]')
231
+
232
+ if (hasTimeout) {
233
+ const list = container.querySelector('[data-wc-list]')
234
+
235
+ // *** If we have the list but don't already have the timeout markup
236
+ if (list && !timeout) {
237
+ list.insertAdjacentHTML('afterend', `
238
+ <div class="wc-timeout" data-wc-timeout>
239
+ <div class="wc-timeout__inner">
240
+ <div class="wc-timeout__message">Webchat will end in <span data-wc-countdown>${this._timeout} seconds</span></div>
241
+ </div>
242
+ <a href="#" class="wc-cancel-timeout-btn" data-wc-cancel-timeout>Continue webchat</a>
243
+ </div>
244
+ `)
245
+ this._scrollToLatest(state.isScroll)
246
+
247
+ // Clear timeout
248
+ const clearBtn = container.querySelector('[data-wc-cancel-timeout]')
249
+ clearBtn.addEventListener('click', e => {
250
+ e.preventDefault()
251
+ this._resetTimeout()
252
+ })
253
+ }
254
+ } else if (timeout) {
255
+ timeout.remove()
256
+ }
257
+ }
258
+
259
+ setAttributes (state) {
260
+ const container = this.container
261
+ if (!container) {
262
+ return
263
+ }
264
+
265
+ const isFullscreen = state.isMobile && state.isOpen
266
+ const root = document.getElementsByTagName('html')[0]
267
+ root.classList.toggle('wc-html', isFullscreen)
268
+ document.body.classList.toggle('wc-body', isFullscreen)
269
+ container.setAttribute('aria-modal', true)
270
+
271
+ const backBtn = container.querySelector('[data-wc-back-btn]')
272
+ const hideBtn = container.querySelector('[data-wc-hide-btn]')
273
+ const closeBtn = container.querySelector('[data-wc-close-btn]')
274
+
275
+ if (backBtn) {
276
+ backBtn.hidden = !isFullscreen
277
+ }
278
+ if (hideBtn) {
279
+ hideBtn.hidden = isFullscreen
280
+ }
281
+ if (closeBtn) {
282
+ closeBtn.hidden = isFullscreen
283
+ }
284
+
285
+ // Toggle textbox behaviour
286
+ const textbox = container.querySelector('[data-wc-textbox]')
287
+ if (textbox) {
288
+ textbox.toggleAttribute('data-wc-enter-submit', !state.isMobile)
289
+ }
290
+ }
291
+ }
292
+
293
+ export default Panel
@@ -0,0 +1,11 @@
1
+ // 'use strict'
2
+ //
3
+ // import { ChatSdk, EnvironmentName, ChatEvent } from '@nice-devone/nice-cxone-chat-web-sdk'
4
+ //
5
+ // class Provider {
6
+ // static authorise () {
7
+ // return null
8
+ // }
9
+ // }
10
+ //
11
+ // export default Provider