@defra/flood-webchat 0.0.1-alpha.10 → 0.0.1-alpha.11

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 +356 -0
  5. package/dist/server.js.map +1 -0
  6. package/docs/development-guide.md +1 -2
  7. package/jest.config.mjs +30 -0
  8. package/main.scss +5 -741
  9. package/package.json +18 -15
  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 +3 -5
  18. package/src/server/{client.js → lib/client.js} +27 -9
  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 -1073
  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.10",
3
+ "version": "0.0.1-alpha.11",
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",
11
- "unit-test": "npm run compile-templates && jest",
8
+ "test": "npm run lint && npm run unit-test",
9
+ "dev": "(cd demo && webpack server)",
10
+ "build": "webpack",
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,12 +29,14 @@
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",
36
38
  "core-js": "^2.6.11",
37
39
  "css-loader": "^6.8.1",
38
- "dotenv": "^16.3.1",
39
40
  "husky": "^8.0.3",
40
41
  "jest": "^29.6.3",
41
42
  "jest-environment-jsdom": "^29.6.3",
@@ -49,15 +50,17 @@
49
50
  "stylelint-config-gds": "^1.0.0",
50
51
  "webpack": "^5.88.2",
51
52
  "webpack-cli": "^5.1.4",
52
- "webpack-dev-server": "^4.15.1"
53
+ "webpack-dev-server": "^4.15.1",
54
+ "webpack-node-externals": "^3.0.0"
53
55
  },
54
56
  "peerDependencies": {
55
57
  "govuk-frontend": "^3.14.0"
56
58
  },
57
59
  "dependencies": {
58
- "@nice-devone/nice-cxone-chat-web-sdk": "1.3.0",
59
- "axios": "^0.24.0",
60
- "nunjucks": "^3.2.4",
61
- "uuid": "^9.0.0"
60
+ "axios": "^1.5.0",
61
+ "jwt-decode": "^3.1.2",
62
+ "querystring": "^0.2.1",
63
+ "react": "^18.2.0",
64
+ "react-dom": "^18.2.0"
62
65
  }
63
66
  }
@@ -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,9 +33,6 @@ 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
38
  const availability = isAvailable ? 'AVAILABLE' : isExistingOnly ? 'EXISTING' : 'UNAVAILABLE'
@@ -1,8 +1,9 @@
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 authenticate = async ({ authorisation, accessKey, accessSecret }) => {
6
7
  const uri = 'https://cxone.niceincontact.com/auth/token'
7
8
 
8
9
  const config = {
@@ -19,25 +20,29 @@ export async function authenticate ({ authorisation, accessKey, accessSecret })
19
20
  username: accessKey,
20
21
  password: accessSecret
21
22
  })
22
- // Cache authentication and re-authenticate when needed (lasts 1 hour?)
23
+
23
24
  const auth = await axios.post(uri, body, config)
25
+
24
26
  return {
25
27
  token: auth.data.access_token,
26
28
  tokenType: auth.data.token_type,
27
- tenantId: extractTenantId(auth.data.id_token)
29
+ tenantId: jwtdecode(auth.data.id_token)?.tenantId
28
30
  }
29
31
  }
30
32
 
31
- export async function getHost ({ tenantId }) {
33
+ const getHost = async ({ tenantId }) => {
32
34
  const uri = `https://cxone.niceincontact.com/.well-known/cxone-configuration?tenantId=${tenantId}`
35
+
33
36
  const config = {
34
37
  signal: AbortSignal.timeout(3000)
35
38
  }
39
+
36
40
  const api = await axios.get(uri, config)
41
+
37
42
  return `api-${api.data.area}.niceincontact.com`
38
43
  }
39
44
 
40
- export async function getActivity ({ tokenType, token, host, skillEndpoint, maxQueueCount }) {
45
+ const getActivity = async ({ tokenType, token, host, skillEndpoint, maxQueueCount }) => {
41
46
  const config = {
42
47
  signal: AbortSignal.timeout(3000),
43
48
  headers: {
@@ -47,15 +52,18 @@ export async function getActivity ({ tokenType, token, host, skillEndpoint, maxQ
47
52
  }
48
53
  }
49
54
  const uri = `https://${host}${skillEndpoint}`
55
+
50
56
  const skill = await axios.get(uri, config)
57
+
51
58
  const activity = skill.data.skillActivity[0]
59
+
52
60
  return {
53
61
  hasCapacity: activity.queueCount < maxQueueCount,
54
62
  hasAgentsAvailable: activity.agentsAvailable >= 1
55
63
  }
56
64
  }
57
65
 
58
- export async function getIsOpen ({ host, token, tokenType, hoursEndpoint }) {
66
+ const getIsOpen = async ({ host, token, tokenType, hoursEndpoint }) => {
59
67
  const config = {
60
68
  signal: AbortSignal.timeout(3000),
61
69
  headers: {
@@ -66,7 +74,17 @@ export async function getIsOpen ({ host, token, tokenType, hoursEndpoint }) {
66
74
  }
67
75
 
68
76
  const uri = `https://${host}${hoursEndpoint}`
77
+
69
78
  const hours = await axios.get(uri, config)
79
+
70
80
  const days = hours.data.resultSet.hoursOfOperationProfiles[0].days
81
+
71
82
  return isWithinHours(days)
72
83
  }
84
+
85
+ module.exports = {
86
+ authenticate,
87
+ getHost,
88
+ getIsOpen,
89
+ getActivity
90
+ }
@@ -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
- }