@defra/flood-webchat 0.0.1-alpha.1 → 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.
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@defra/flood-webchat",
3
- "version": "0.0.1-alpha.1",
3
+ "version": "0.0.1-alpha.11",
4
4
  "description": "",
5
- "main": "src/main.mjs",
6
- "browser": "src/browser.mjs",
5
+ "main": "dist/server.js",
6
+ "browser": "dist/client.js",
7
7
  "scripts": {
8
8
  "test": "npm run lint && npm run unit-test",
9
9
  "dev": "(cd demo && webpack server)",
10
+ "build": "webpack",
10
11
  "unit-test": "jest",
11
- "lint": "standard",
12
+ "lint": "npm run lint:js && npm run lint:scss",
13
+ "lint:fix": "npm run lint:js -- --fix && npm run lint:scss -- --fix",
14
+ "lint:js": "standard",
15
+ "lint:scss": "stylelint \"**/*.scss\"",
12
16
  "prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') { throw e } }\""
13
17
  },
14
18
  "standard": {
@@ -25,6 +29,9 @@
25
29
  "devDependencies": {
26
30
  "@babel/core": "^7.22.10",
27
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",
28
35
  "@types/jest": "^29.5.4",
29
36
  "babel-jest": "^29.6.4",
30
37
  "babel-loader": "^9.1.3",
@@ -34,15 +41,26 @@
34
41
  "jest": "^29.6.3",
35
42
  "jest-environment-jsdom": "^29.6.3",
36
43
  "mini-css-extract-plugin": "^2.7.6",
44
+ "node-cache": "^5.1.2",
37
45
  "node-sass": "^9.0.0",
38
46
  "sass": "^1.66.1",
39
47
  "sass-loader": "^13.3.2",
40
48
  "standard": "^17.1.0",
49
+ "stylelint": "^15.10.3",
50
+ "stylelint-config-gds": "^1.0.0",
41
51
  "webpack": "^5.88.2",
42
52
  "webpack-cli": "^5.1.4",
43
- "webpack-dev-server": "^4.15.1"
53
+ "webpack-dev-server": "^4.15.1",
54
+ "webpack-node-externals": "^3.0.0"
44
55
  },
45
56
  "peerDependencies": {
46
57
  "govuk-frontend": "^3.14.0"
58
+ },
59
+ "dependencies": {
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"
47
65
  }
48
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
+ }
@@ -0,0 +1,44 @@
1
+ const { authenticate, getHost, getIsOpen, getActivity } = require('./lib/client.js')
2
+
3
+ /**
4
+ * Returns webchat availability
5
+ * @param options {object}
6
+ * @param options.clientId {string}
7
+ * @param options.clientSecret {string}
8
+ * @param options.accessKey {string}
9
+ * @param options.accessSecret {string}
10
+ * @param options.skillEndpoint {string}
11
+ * @param options.hoursEndpoint {string}
12
+ * @param options.maxQueueCount {string}
13
+ * @returns {Promise<{date: Date, availability: (string)}>}
14
+ */
15
+ module.exports = async function getAvailability ({
16
+ clientId,
17
+ clientSecret,
18
+ accessKey,
19
+ accessSecret,
20
+ skillEndpoint,
21
+ hoursEndpoint,
22
+ maxQueueCount
23
+ }) {
24
+ const authorisation = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64')
25
+
26
+ // Cache authentication and re-authenticate when needed (lasts 1 hour?)
27
+ const { tenantId, token, tokenType } = await authenticate({ authorisation, accessKey, accessSecret })
28
+
29
+ const host = await getHost({ tenantId })
30
+
31
+ const [{ hasCapacity, hasAgentsAvailable }, isOpen] = await Promise.all([
32
+ getActivity({ tokenType, token, host, skillEndpoint, maxQueueCount }),
33
+ getIsOpen({ token, tokenType, host, hoursEndpoint })
34
+ ])
35
+
36
+ const isAvailable = isOpen && hasAgentsAvailable && hasCapacity
37
+ const isExistingOnly = isOpen && hasAgentsAvailable && !hasCapacity
38
+ const availability = isAvailable ? 'AVAILABLE' : isExistingOnly ? 'EXISTING' : 'UNAVAILABLE'
39
+
40
+ return {
41
+ date: new Date(),
42
+ availability
43
+ }
44
+ }
@@ -0,0 +1,90 @@
1
+ const querystring = require('querystring')
2
+ const axios = require('axios')
3
+ const jwtdecode = require('jwt-decode')
4
+ const { isWithinHours } = require('./utils.js')
5
+
6
+ const authenticate = async ({ authorisation, accessKey, accessSecret }) => {
7
+ const uri = 'https://cxone.niceincontact.com/auth/token'
8
+
9
+ const config = {
10
+ signal: AbortSignal.timeout(3000),
11
+ headers: {
12
+ Host: 'eu1.niceincontact.com',
13
+ 'Content-Type': 'application/x-www-form-urlencoded',
14
+ Authorization: authorisation
15
+ }
16
+ }
17
+
18
+ const body = querystring.stringify({
19
+ grant_type: 'password',
20
+ username: accessKey,
21
+ password: accessSecret
22
+ })
23
+
24
+ const auth = await axios.post(uri, body, config)
25
+
26
+ return {
27
+ token: auth.data.access_token,
28
+ tokenType: auth.data.token_type,
29
+ tenantId: jwtdecode(auth.data.id_token)?.tenantId
30
+ }
31
+ }
32
+
33
+ const getHost = async ({ tenantId }) => {
34
+ const uri = `https://cxone.niceincontact.com/.well-known/cxone-configuration?tenantId=${tenantId}`
35
+
36
+ const config = {
37
+ signal: AbortSignal.timeout(3000)
38
+ }
39
+
40
+ const api = await axios.get(uri, config)
41
+
42
+ return `api-${api.data.area}.niceincontact.com`
43
+ }
44
+
45
+ const getActivity = async ({ tokenType, token, host, skillEndpoint, maxQueueCount }) => {
46
+ const config = {
47
+ signal: AbortSignal.timeout(3000),
48
+ headers: {
49
+ Host: host,
50
+ Authorization: `${tokenType} ${token}`,
51
+ 'Content-Type': 'application/x-www-form-urlencoded'
52
+ }
53
+ }
54
+ const uri = `https://${host}${skillEndpoint}`
55
+
56
+ const skill = await axios.get(uri, config)
57
+
58
+ const activity = skill.data.skillActivity[0]
59
+
60
+ return {
61
+ hasCapacity: activity.queueCount < maxQueueCount,
62
+ hasAgentsAvailable: activity.agentsAvailable >= 1
63
+ }
64
+ }
65
+
66
+ const getIsOpen = async ({ host, token, tokenType, hoursEndpoint }) => {
67
+ const config = {
68
+ signal: AbortSignal.timeout(3000),
69
+ headers: {
70
+ Host: 'api-l36.niceincontact.com',
71
+ Authorization: `${tokenType} ${token}`,
72
+ 'Content-Type': 'application/x-www-form-urlencoded'
73
+ }
74
+ }
75
+
76
+ const uri = `https://${host}${hoursEndpoint}`
77
+
78
+ const hours = await axios.get(uri, config)
79
+
80
+ const days = hours.data.resultSet.hoursOfOperationProfiles[0].days
81
+
82
+ return isWithinHours(days)
83
+ }
84
+
85
+ module.exports = {
86
+ authenticate,
87
+ getHost,
88
+ getIsOpen,
89
+ getActivity
90
+ }
@@ -0,0 +1,14 @@
1
+ const isWithinHours = days => {
2
+ const now = new Date()
3
+ const name = now.toLocaleDateString('en-GB', { weekday: 'long' })
4
+ const day = days.find(d => d.day.toLowerCase() === name.toLowerCase())
5
+ const date = now.toLocaleDateString('en-GB').split('/')
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
+
9
+ return now.getTime() >= Date.parse(open) && now.getTime() <= Date.parse(close)
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
+ }
package/src/browser.mjs DELETED
@@ -1,3 +0,0 @@
1
- export function init (value) {
2
- return value === true
3
- }
package/src/main.mjs DELETED
File without changes