@defra/flood-webchat 0.0.1-alpha.9 → 0.0.1-beta.2

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.
Files changed (37) hide show
  1. package/README.md +1 -83
  2. package/dist/client.js +445 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/server.js +362 -0
  5. package/dist/server.js.map +1 -0
  6. package/docs/development-guide.md +1 -2
  7. package/jest.config.mjs +29 -0
  8. package/main.scss +5 -741
  9. package/package.json +18 -13
  10. package/src/client/components/availability/availability.jsx +101 -0
  11. package/src/client/components/availability/availability.scss +89 -0
  12. package/src/client/index.jsx +16 -0
  13. package/src/client/lib/check-availability.js +7 -0
  14. package/src/client/lib/classnames.js +1 -0
  15. package/src/client/lib/external-stores.js +4 -0
  16. package/src/client/lib/external-sync-store.js +42 -0
  17. package/src/server/index.js +11 -6
  18. package/src/server/{client.js → lib/client.js} +32 -12
  19. package/src/server/{utils.js → lib/utils.js} +8 -13
  20. package/webpack.config.mjs +32 -0
  21. package/assets/audio/notification.mp3 +0 -0
  22. package/babel.config.cjs +0 -3
  23. package/dist/templates.js +0 -554
  24. package/src/client/index.js +0 -14
  25. package/src/client/lib/availability.js +0 -75
  26. package/src/client/lib/config.js +0 -17
  27. package/src/client/lib/keyboard.js +0 -149
  28. package/src/client/lib/notification.js +0 -59
  29. package/src/client/lib/nunjucks.js +0 -3
  30. package/src/client/lib/panel.js +0 -293
  31. package/src/client/lib/provider.js +0 -11
  32. package/src/client/lib/skiplink.js +0 -27
  33. package/src/client/lib/state.js +0 -82
  34. package/src/client/lib/transcript.js +0 -34
  35. package/src/client/lib/utils.js +0 -190
  36. package/src/client/lib/webchat.js +0 -1075
  37. package/src/style/_mixins.scss +0 -13
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "@defra/flood-webchat",
3
- "version": "0.0.1-alpha.9",
3
+ "version": "0.0.1-beta.2",
4
4
  "description": "",
5
- "type": "module",
6
- "main": "src/server/index.js",
7
- "browser": "src/client/index.js",
5
+ "main": "dist/server.js",
6
+ "browser": "dist/client.js",
8
7
  "scripts": {
9
- "test": "npm run compile-templates && npm run lint && npm run unit-test",
10
- "dev": "npm run compile-templates && node -r dotenv/config node_modules/.bin/webpack server --config demo/webpack.config.mjs",
8
+ "test": "npm run lint && npm run unit-test",
9
+ "dev": "(cd demo && webpack server)",
10
+ "build": "webpack",
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 } }\"",
17
- "compile-templates": "mkdir -p dist && nunjucks-precompile templates > ./dist/templates.js"
16
+ "prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') { throw e } }\""
18
17
  },
19
18
  "standard": {
20
19
  "ignore": [
@@ -30,6 +29,9 @@
30
29
  "devDependencies": {
31
30
  "@babel/core": "^7.22.10",
32
31
  "@babel/preset-env": "^7.22.10",
32
+ "@babel/preset-react": "^7.22.15",
33
+ "@testing-library/react": "^14.0.0",
34
+ "@testing-library/user-event": "^14.5.1",
33
35
  "@types/jest": "^29.5.4",
34
36
  "babel-jest": "^29.6.4",
35
37
  "babel-loader": "^9.1.3",
@@ -49,14 +51,17 @@
49
51
  "stylelint-config-gds": "^1.0.0",
50
52
  "webpack": "^5.88.2",
51
53
  "webpack-cli": "^5.1.4",
52
- "webpack-dev-server": "^4.15.1"
54
+ "webpack-dev-server": "^4.15.1",
55
+ "webpack-node-externals": "^3.0.0"
53
56
  },
54
57
  "peerDependencies": {
55
- "govuk-frontend": "^3.14.0"
58
+ "govuk-frontend": "^4.0.0"
56
59
  },
57
60
  "dependencies": {
58
- "@nice-devone/nice-cxone-chat-web-sdk": "1.3.0",
59
- "axios": "^0.24.0",
60
- "nunjucks": "^3.2.4"
61
+ "axios": "^1.5.0",
62
+ "jwt-decode": "^3.1.2",
63
+ "querystring": "^0.2.1",
64
+ "react": "^18.2.0",
65
+ "react-dom": "^18.2.0"
61
66
  }
62
67
  }
@@ -0,0 +1,101 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { classnames } from '../../lib/classnames'
3
+ import { useMessageThread, useWebchatOpenState } from '../../lib/external-stores'
4
+
5
+ export function Availability (props) {
6
+ const [isOpen, setOpen] = useWebchatOpenState()
7
+ const [isFixed, setFixed] = useState(false)
8
+ const buttonRef = useRef()
9
+ const onClick = () => {
10
+ setOpen(!isOpen)
11
+ }
12
+ const onKeyDown = event => {
13
+ if (event.key === ' ') {
14
+ event.preventDefault()
15
+ }
16
+ }
17
+ const onKeyUp = event => {
18
+ if (event.key === ' ') {
19
+ setOpen(!isOpen)
20
+ }
21
+ }
22
+
23
+ const intersectionCallback = entries => {
24
+ const [entry] = entries
25
+ const isBelowFold = !entry.isIntersecting && entry.boundingClientRect.top > 0
26
+ setFixed(!isOpen && isBelowFold)
27
+ }
28
+
29
+ useEffect(() => {
30
+ const observer = new window.IntersectionObserver(intersectionCallback, {
31
+ rootMargin: '35px'
32
+ })
33
+ const parentElement = buttonRef.current?.parentElement
34
+ if (parentElement) {
35
+ observer.observe(parentElement)
36
+ }
37
+ return () => {
38
+ if (parentElement) {
39
+ observer.unobserve(parentElement)
40
+ }
41
+ }
42
+ }, [buttonRef, isOpen])
43
+
44
+ useEffect(() => {
45
+ document.documentElement.classList.toggle('wc-scroll-padding', isFixed)
46
+ document.body.classList.toggle('wc-scroll-padding', isFixed)
47
+ }, [isFixed])
48
+
49
+ switch (props.availability) {
50
+ case 'AVAILABLE':
51
+ return (
52
+ <div
53
+ className={classnames('wc-availability', isFixed && 'wc-availability--fixed')}
54
+ ref={buttonRef}
55
+ >
56
+ <div className='wc-availability__inner'>
57
+ <a
58
+ className='wc-availability__link'
59
+ href='#webchat' role='button' draggable='false'
60
+ onClick={onClick}
61
+ onKeyUp={onKeyUp}
62
+ onKeyDown={onKeyDown}
63
+ >
64
+ <AvailabilityContent />
65
+ </a>
66
+ </div>
67
+ </div>
68
+ )
69
+ case 'EXISTING':
70
+ case 'UNAVAILABLE':
71
+ return (
72
+ <p className='govuk-body'>When it is available, a 'start chat' link will appear.</p>
73
+ )
74
+ default:
75
+ return (
76
+ <p className='govuk-body'>Checking availability</p>
77
+ )
78
+ }
79
+ }
80
+
81
+ function AvailabilityContent () {
82
+ const [thread] = useMessageThread()
83
+ const unreadMessageCount = thread.filter(message => !message.read).length
84
+ if (!thread.length) {
85
+ return (
86
+ <>
87
+ Start Chat
88
+ </>
89
+ )
90
+ }
91
+ return (
92
+ <>
93
+ Show Chat {!!unreadMessageCount && (
94
+ <>
95
+ <span className='wc-availability__unseen'>{unreadMessageCount}</span>
96
+ <span className='govuk-visually-hidden'> {unreadMessageCount === 1 ? 'new message' : 'new messages'}</span>
97
+ </>
98
+ )}
99
+ </>
100
+ )
101
+ }
@@ -0,0 +1,89 @@
1
+
2
+ .wc-availability__inner {
3
+ max-width: 960px;
4
+ margin-left: auto;
5
+ margin-right: auto;
6
+ }
7
+
8
+ .wc-availability--fixed {
9
+ position: fixed;
10
+ bottom: 0;
11
+ left: 0;
12
+ right: 0;
13
+ padding-left: 15px;
14
+ padding-right: 15px;
15
+
16
+ @include mq ($from: 'tablet') {
17
+ padding-left: 30px;
18
+ padding-right: 30px;
19
+ }
20
+
21
+ background-color: govuk-colour('light-grey');
22
+
23
+ .wc-availability__link {
24
+ margin-top: 10px;
25
+ margin-bottom: 10px;
26
+ }
27
+ }
28
+
29
+ .wc-availability__link {
30
+ @include govuk-font($size: 19);
31
+ position:relative;
32
+ display:inline-block;
33
+ margin-right: 5px;
34
+ margin-bottom: 0;
35
+ fill: govuk-colour('black');
36
+
37
+ @include mq ($from: tablet) {
38
+ margin-right: 10px;
39
+ }
40
+
41
+ .govuk-visually-hidden, .wc-availability__unseen {
42
+ pointer-events: none;
43
+ }
44
+
45
+ &:hover {
46
+ color: $govuk-link-hover-colour;
47
+ }
48
+
49
+ &:active {
50
+ color: govuk-colour('black');
51
+ }
52
+
53
+ &:visited {
54
+ color: $govuk-link-colour;
55
+ }
56
+
57
+ &:focus {
58
+ box-shadow: none;
59
+ background-color: transparent;
60
+ text-decoration: underline;
61
+ color: $govuk-focus-colour;
62
+ }
63
+
64
+ &:hover:not(:focus):not(:active) {
65
+ color: $govuk-link-hover-colour;
66
+
67
+ .wc-availability__unseen {
68
+ background-color: govuk-colour('dark-blue');
69
+ }
70
+ }
71
+ }
72
+
73
+ .wc-availability__unseen {
74
+ display: inline-block;
75
+ position: relative;
76
+ border-radius: 10px;
77
+ background-color: govuk-colour('black');
78
+ color: govuk-colour('white');
79
+ font-weight: bold;
80
+ font-size: 14px;
81
+ padding: 0 6px;
82
+ margin-left: 5px;
83
+
84
+ @include mq ($from: tablet) {
85
+ top: -1px;
86
+ padding-left: 7px;
87
+ padding-right: 7px;
88
+ }
89
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { Availability } from './components/availability/availability.jsx'
4
+ import { checkAvailability } from './lib/check-availability'
5
+
6
+ export async function init (container, options) {
7
+ const root = createRoot(container)
8
+ let availability
9
+ try {
10
+ const result = await checkAvailability(options.availabilityEndpoint)
11
+ availability = result.availability
12
+ } catch (e) {
13
+ availability = 'UNAVAILABLE'
14
+ }
15
+ root.render(<Availability availability={availability} />)
16
+ }
@@ -0,0 +1,7 @@
1
+ export async function checkAvailability (endpoint) {
2
+ const response = await fetch(endpoint)
3
+ const data = await response.json()
4
+ return {
5
+ availability: data.availability || 'UNAVAILABLE'
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ export const classnames = (...classes) => classes.filter(classname => classname && typeof classname === 'string').join(' ')
@@ -0,0 +1,4 @@
1
+ import ExternalSyncStore from './external-sync-store'
2
+
3
+ export const useWebchatOpenState = ExternalSyncStore.create(false)
4
+ export const useMessageThread = ExternalSyncStore.create([])
@@ -0,0 +1,42 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ export default class ExternalSyncStore {
4
+ constructor (initialValue) {
5
+ this._value = initialValue
6
+ this._listeners = []
7
+ }
8
+
9
+ update (value) {
10
+ this._value = value
11
+ this.emitChange()
12
+ }
13
+
14
+ subscribe (listener) {
15
+ this._listeners = [...this._listeners, listener]
16
+ return () => {
17
+ this._listeners = this._listeners.filter(l => l !== listener)
18
+ }
19
+ }
20
+
21
+ emitChange () {
22
+ for (const listener of this._listeners) {
23
+ listener()
24
+ }
25
+ }
26
+
27
+ getSnapshot () {
28
+ return this._value
29
+ }
30
+
31
+ static create (initialValue) {
32
+ const store = new ExternalSyncStore(initialValue)
33
+ const boundSubscribe = store.subscribe.bind(store)
34
+ const boundGetSnapshot = store.getSnapshot.bind(store)
35
+ const boundUpdate = store.update.bind(store)
36
+
37
+ return () => [
38
+ useSyncExternalStore(boundSubscribe, boundGetSnapshot),
39
+ boundUpdate
40
+ ]
41
+ }
42
+ }
@@ -1,4 +1,4 @@
1
- import { authenticate, getHost, getIsOpen, getActivity } from './client.js'
1
+ const { authenticate, getHost, getIsOpen, getActivity } = require('./lib/client.js')
2
2
 
3
3
  /**
4
4
  * Returns webchat availability
@@ -12,7 +12,7 @@ import { authenticate, getHost, getIsOpen, getActivity } from './client.js'
12
12
  * @param options.maxQueueCount {string}
13
13
  * @returns {Promise<{date: Date, availability: (string)}>}
14
14
  */
15
- export default async function getAvailability ({
15
+ module.exports = async function getAvailability ({
16
16
  clientId,
17
17
  clientSecret,
18
18
  accessKey,
@@ -25,6 +25,7 @@ export default async function getAvailability ({
25
25
 
26
26
  // Cache authentication and re-authenticate when needed (lasts 1 hour?)
27
27
  const { tenantId, token, tokenType } = await authenticate({ authorisation, accessKey, accessSecret })
28
+
28
29
  const host = await getHost({ tenantId })
29
30
 
30
31
  const [{ hasCapacity, hasAgentsAvailable }, isOpen] = await Promise.all([
@@ -32,12 +33,16 @@ export default async function getAvailability ({
32
33
  getIsOpen({ token, tokenType, host, hoursEndpoint })
33
34
  ])
34
35
 
35
- // Hours of operation
36
-
37
- // Availability
38
36
  const isAvailable = isOpen && hasAgentsAvailable && hasCapacity
39
37
  const isExistingOnly = isOpen && hasAgentsAvailable && !hasCapacity
40
- const availability = isAvailable ? 'AVAILABLE' : isExistingOnly ? 'EXISTING' : 'UNAVAILABLE'
38
+
39
+ let availability = 'UNAVAILABLE'
40
+
41
+ if (isAvailable) {
42
+ availability = 'AVAILABLE'
43
+ } else if (isExistingOnly) {
44
+ availability = 'EXISTING'
45
+ }
41
46
 
42
47
  return {
43
48
  date: new Date(),
@@ -1,15 +1,18 @@
1
- import querystring from 'querystring'
2
- import axios from 'axios'
3
- import { isWithinHours, extractTenantId } from './utils.js'
1
+ const querystring = require('querystring')
2
+ const axios = require('axios')
3
+ const jwtdecode = require('jwt-decode')
4
+ const { isWithinHours } = require('./utils.js')
4
5
 
5
- export async function authenticate ({ authorisation, accessKey, accessSecret }) {
6
+ const contentType = 'application/x-www-form-urlencoded'
7
+
8
+ const authenticate = async ({ authorisation, accessKey, accessSecret }) => {
6
9
  const uri = 'https://cxone.niceincontact.com/auth/token'
7
10
 
8
11
  const config = {
9
12
  signal: AbortSignal.timeout(3000),
10
13
  headers: {
11
14
  Host: 'eu1.niceincontact.com',
12
- 'Content-Type': 'application/x-www-form-urlencoded',
15
+ 'Content-Type': contentType,
13
16
  Authorization: authorisation
14
17
  }
15
18
  }
@@ -19,54 +22,71 @@ export async function authenticate ({ authorisation, accessKey, accessSecret })
19
22
  username: accessKey,
20
23
  password: accessSecret
21
24
  })
22
- // Cache authentication and re-authenticate when needed (lasts 1 hour?)
25
+
23
26
  const auth = await axios.post(uri, body, config)
27
+
24
28
  return {
25
29
  token: auth.data.access_token,
26
30
  tokenType: auth.data.token_type,
27
- tenantId: extractTenantId(auth.data.id_token)
31
+ tenantId: jwtdecode(auth.data.id_token)?.tenantId
28
32
  }
29
33
  }
30
34
 
31
- export async function getHost ({ tenantId }) {
35
+ const getHost = async ({ tenantId }) => {
32
36
  const uri = `https://cxone.niceincontact.com/.well-known/cxone-configuration?tenantId=${tenantId}`
37
+
33
38
  const config = {
34
39
  signal: AbortSignal.timeout(3000)
35
40
  }
41
+
36
42
  const api = await axios.get(uri, config)
43
+
37
44
  return `api-${api.data.area}.niceincontact.com`
38
45
  }
39
46
 
40
- export async function getActivity ({ tokenType, token, host, skillEndpoint, maxQueueCount }) {
47
+ const getActivity = async ({ tokenType, token, host, skillEndpoint, maxQueueCount }) => {
41
48
  const config = {
42
49
  signal: AbortSignal.timeout(3000),
43
50
  headers: {
44
51
  Host: host,
45
52
  Authorization: `${tokenType} ${token}`,
46
- 'Content-Type': 'application/x-www-form-urlencoded'
53
+ 'Content-Type': contentType
47
54
  }
48
55
  }
49
56
  const uri = `https://${host}${skillEndpoint}`
57
+
50
58
  const skill = await axios.get(uri, config)
59
+
51
60
  const activity = skill.data.skillActivity[0]
61
+
52
62
  return {
53
63
  hasCapacity: activity.queueCount < maxQueueCount,
54
64
  hasAgentsAvailable: activity.agentsAvailable >= 1
55
65
  }
56
66
  }
57
67
 
58
- export async function getIsOpen ({ host, token, tokenType, hoursEndpoint }) {
68
+ const getIsOpen = async ({ host, token, tokenType, hoursEndpoint }) => {
59
69
  const config = {
60
70
  signal: AbortSignal.timeout(3000),
61
71
  headers: {
62
72
  Host: 'api-l36.niceincontact.com',
63
73
  Authorization: `${tokenType} ${token}`,
64
- 'Content-Type': 'application/x-www-form-urlencoded'
74
+ 'Content-Type': contentType
65
75
  }
66
76
  }
67
77
 
68
78
  const uri = `https://${host}${hoursEndpoint}`
79
+
69
80
  const hours = await axios.get(uri, config)
81
+
70
82
  const days = hours.data.resultSet.hoursOfOperationProfiles[0].days
83
+
71
84
  return isWithinHours(days)
72
85
  }
86
+
87
+ module.exports = {
88
+ authenticate,
89
+ getHost,
90
+ getIsOpen,
91
+ getActivity
92
+ }
@@ -1,19 +1,14 @@
1
- export const extractTenantId = (token) => {
2
- const base64Url = token.split('.')[1]
3
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
4
- const jsonPayload = decodeURIComponent(Buffer.from(base64, 'base64').toString('ascii').split('').map(c => {
5
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
6
- }).join(''))
7
-
8
- return JSON.parse(jsonPayload).tenantId
9
- }
10
-
11
- export const isWithinHours = (days) => {
1
+ const isWithinHours = days => {
12
2
  const now = new Date()
13
3
  const name = now.toLocaleDateString('en-GB', { weekday: 'long' })
14
4
  const day = days.find(d => d.day.toLowerCase() === name.toLowerCase())
15
5
  const date = now.toLocaleDateString('en-GB').split('/')
16
- const open = `${date[2]}-${date[1]}-${date[0]}T${day.openTime}`
17
- const close = `${date[2]}-${date[1]}-${date[0]}T${day.closeTime}`
6
+ const open = `${date[2]}-${date[1]}-${date[0]}T${day.openTime}.000Z`
7
+ const close = `${date[2]}-${date[1]}-${date[0]}T${day.closeTime}.000Z`
8
+
18
9
  return now.getTime() >= Date.parse(open) && now.getTime() <= Date.parse(close)
19
10
  }
11
+
12
+ module.exports = {
13
+ isWithinHours
14
+ }
@@ -0,0 +1,32 @@
1
+ import path from 'path'
2
+
3
+ import nodeExternals from 'webpack-node-externals'
4
+
5
+ const __dirname = path.dirname(new URL(import.meta.url).pathname)
6
+
7
+ export default {
8
+ entry: {
9
+ client: path.join(__dirname, 'src/client/index.jsx'),
10
+ server: path.join(__dirname, 'src/server/index.js')
11
+ },
12
+ devtool: 'source-map',
13
+ mode: 'development',
14
+ output: {
15
+ path: path.resolve(__dirname, 'dist'),
16
+ library: {
17
+ type: 'commonjs2'
18
+ }
19
+ },
20
+ target: 'node',
21
+ externals: [nodeExternals()],
22
+ module: {
23
+ rules: [
24
+ {
25
+ test: /\.jsx?$/i,
26
+ use: [
27
+ 'babel-loader'
28
+ ]
29
+ }
30
+ ]
31
+ }
32
+ }
Binary file
package/babel.config.cjs DELETED
@@ -1,3 +0,0 @@
1
- module.exports = {
2
- presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
3
- }