@defra/flood-webchat 0.0.1-alpha.8 → 0.0.1-beta.1

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.
@@ -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,3 +1,51 @@
1
- export function foo (value) {
2
- return value === 'bar'
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
+
39
+ let availability = 'UNAVAILABLE'
40
+
41
+ if (isAvailable) {
42
+ availability = 'AVAILABLE'
43
+ } else if (isExistingOnly) {
44
+ availability = 'EXISTING'
45
+ }
46
+
47
+ return {
48
+ date: new Date(),
49
+ availability
50
+ }
3
51
  }
@@ -0,0 +1,92 @@
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 contentType = 'application/x-www-form-urlencoded'
7
+
8
+ const authenticate = async ({ authorisation, accessKey, accessSecret }) => {
9
+ const uri = 'https://cxone.niceincontact.com/auth/token'
10
+
11
+ const config = {
12
+ signal: AbortSignal.timeout(3000),
13
+ headers: {
14
+ Host: 'eu1.niceincontact.com',
15
+ 'Content-Type': contentType,
16
+ Authorization: authorisation
17
+ }
18
+ }
19
+
20
+ const body = querystring.stringify({
21
+ grant_type: 'password',
22
+ username: accessKey,
23
+ password: accessSecret
24
+ })
25
+
26
+ const auth = await axios.post(uri, body, config)
27
+
28
+ return {
29
+ token: auth.data.access_token,
30
+ tokenType: auth.data.token_type,
31
+ tenantId: jwtdecode(auth.data.id_token)?.tenantId
32
+ }
33
+ }
34
+
35
+ const getHost = async ({ tenantId }) => {
36
+ const uri = `https://cxone.niceincontact.com/.well-known/cxone-configuration?tenantId=${tenantId}`
37
+
38
+ const config = {
39
+ signal: AbortSignal.timeout(3000)
40
+ }
41
+
42
+ const api = await axios.get(uri, config)
43
+
44
+ return `api-${api.data.area}.niceincontact.com`
45
+ }
46
+
47
+ const getActivity = async ({ tokenType, token, host, skillEndpoint, maxQueueCount }) => {
48
+ const config = {
49
+ signal: AbortSignal.timeout(3000),
50
+ headers: {
51
+ Host: host,
52
+ Authorization: `${tokenType} ${token}`,
53
+ 'Content-Type': contentType
54
+ }
55
+ }
56
+ const uri = `https://${host}${skillEndpoint}`
57
+
58
+ const skill = await axios.get(uri, config)
59
+
60
+ const activity = skill.data.skillActivity[0]
61
+
62
+ return {
63
+ hasCapacity: activity.queueCount < maxQueueCount,
64
+ hasAgentsAvailable: activity.agentsAvailable >= 1
65
+ }
66
+ }
67
+
68
+ const getIsOpen = async ({ host, token, tokenType, hoursEndpoint }) => {
69
+ const config = {
70
+ signal: AbortSignal.timeout(3000),
71
+ headers: {
72
+ Host: 'api-l36.niceincontact.com',
73
+ Authorization: `${tokenType} ${token}`,
74
+ 'Content-Type': contentType
75
+ }
76
+ }
77
+
78
+ const uri = `https://${host}${hoursEndpoint}`
79
+
80
+ const hours = await axios.get(uri, config)
81
+
82
+ const days = hours.data.resultSet.hoursOfOperationProfiles[0].days
83
+
84
+ return isWithinHours(days)
85
+ }
86
+
87
+ module.exports = {
88
+ authenticate,
89
+ getHost,
90
+ getIsOpen,
91
+ getActivity
92
+ }
@@ -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
+ }
@@ -1,3 +0,0 @@
1
- export function init (value) {
2
- return value === true
3
- }