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

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 (56) hide show
  1. package/README.md +3 -3
  2. package/dist/client.js +1547 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/server.js +303 -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 +7 -742
  9. package/package.json +23 -13
  10. package/src/client/components/availability/availability.jsx +98 -0
  11. package/src/client/components/availability/availability.scss +89 -0
  12. package/src/client/components/chat/chat.jsx +143 -0
  13. package/src/client/components/chat/chat.scss +134 -0
  14. package/src/client/components/message/message.jsx +20 -0
  15. package/src/client/components/panel/panel-footer.jsx +9 -0
  16. package/src/client/components/panel/panel-header.jsx +39 -0
  17. package/src/client/components/panel/panel.jsx +109 -0
  18. package/src/client/components/panel/panel.scss +72 -0
  19. package/src/client/components/screens/end-chat.jsx +17 -0
  20. package/src/client/components/screens/pre-chat.jsx +55 -0
  21. package/src/client/components/screens/request-chat.jsx +170 -0
  22. package/src/client/components/screens/unavailable.jsx +23 -0
  23. package/src/client/hooks/useFocusedElements.js +71 -0
  24. package/src/client/hooks/useTextareaAutosize.js +13 -0
  25. package/src/client/index.jsx +30 -0
  26. package/src/client/lib/check-availability.js +8 -0
  27. package/src/client/lib/classnames.js +1 -0
  28. package/src/client/lib/transform-messages.js +14 -0
  29. package/src/client/store/AppProvider.jsx +114 -0
  30. package/src/client/store/actions-map.js +97 -0
  31. package/src/client/store/reducer.js +29 -0
  32. package/src/client/store/useApp.js +5 -0
  33. package/src/client/store/useChatSdk.js +37 -0
  34. package/src/server/index.js +25 -11
  35. package/src/server/lib/client.js +88 -0
  36. package/src/server/lib/utils.js +23 -0
  37. package/webpack.config.mjs +32 -0
  38. package/assets/audio/notification.mp3 +0 -0
  39. package/babel.config.cjs +0 -3
  40. package/dist/templates.js +0 -554
  41. package/src/client/index.js +0 -14
  42. package/src/client/lib/availability.js +0 -75
  43. package/src/client/lib/config.js +0 -17
  44. package/src/client/lib/keyboard.js +0 -149
  45. package/src/client/lib/notification.js +0 -59
  46. package/src/client/lib/nunjucks.js +0 -3
  47. package/src/client/lib/panel.js +0 -293
  48. package/src/client/lib/provider.js +0 -11
  49. package/src/client/lib/skiplink.js +0 -27
  50. package/src/client/lib/state.js +0 -82
  51. package/src/client/lib/transcript.js +0 -34
  52. package/src/client/lib/utils.js +0 -190
  53. package/src/client/lib/webchat.js +0 -1075
  54. package/src/server/client.js +0 -72
  55. package/src/server/utils.js +0 -19
  56. 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.10",
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,10 @@
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/jest-dom": "^6.1.3",
34
+ "@testing-library/react": "^14.0.0",
35
+ "@testing-library/user-event": "^14.5.1",
33
36
  "@types/jest": "^29.5.4",
34
37
  "babel-jest": "^29.6.4",
35
38
  "babel-loader": "^9.1.3",
@@ -49,14 +52,21 @@
49
52
  "stylelint-config-gds": "^1.0.0",
50
53
  "webpack": "^5.88.2",
51
54
  "webpack-cli": "^5.1.4",
52
- "webpack-dev-server": "^4.15.1"
55
+ "webpack-dev-server": "^4.15.1",
56
+ "webpack-node-externals": "^3.0.0"
53
57
  },
54
58
  "peerDependencies": {
55
- "govuk-frontend": "^3.14.0"
59
+ "govuk-frontend": "^4.0.0"
56
60
  },
57
61
  "dependencies": {
58
- "@nice-devone/nice-cxone-chat-web-sdk": "1.3.0",
59
- "axios": "^0.24.0",
60
- "nunjucks": "^3.2.4"
62
+ "@nice-devone/nice-cxone-chat-web-sdk": "^1.3.0",
63
+ "axios": "^1.5.0",
64
+ "jwt-decode": "^3.1.2",
65
+ "luxon": "^3.4.3",
66
+ "querystring": "^0.2.1",
67
+ "react": "^18.2.0",
68
+ "react-dom": "^18.2.0",
69
+ "sanitize-html": "^2.11.0",
70
+ "uuid": "^9.0.1"
61
71
  }
62
72
  }
@@ -0,0 +1,98 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+
3
+ import { classnames } from '../../lib/classnames'
4
+ import { Panel } from '../panel/panel.jsx'
5
+ import { useApp } from '../../store/useApp'
6
+
7
+ export function Availability () {
8
+ const { availability, messages, isChatOpen, setChatVisibility } = useApp()
9
+
10
+ const [isFixed, setIsFixed] = useState(false)
11
+ const buttonRef = useRef()
12
+
13
+ const onClick = () => {
14
+ setChatVisibility(!isChatOpen)
15
+ }
16
+
17
+ const onKeyDown = event => {
18
+ if (event.key === ' ') {
19
+ event.preventDefault()
20
+ }
21
+ }
22
+
23
+ const onKeyUp = event => {
24
+ if (event.key === ' ') {
25
+ setChatVisibility(!isChatOpen)
26
+ }
27
+ }
28
+
29
+ const intersectionCallback = entries => {
30
+ const [entry] = entries
31
+ const isBelowFold = !entry.isIntersecting && entry.boundingClientRect.top > 0
32
+ setIsFixed(!isChatOpen && isBelowFold)
33
+ }
34
+
35
+ useEffect(() => {
36
+ const observer = new window.IntersectionObserver(intersectionCallback, {
37
+ rootMargin: '35px'
38
+ })
39
+
40
+ const parentElement = buttonRef.current?.parentElement
41
+
42
+ if (parentElement) {
43
+ observer.observe(parentElement)
44
+ }
45
+
46
+ return () => {
47
+ if (parentElement) {
48
+ observer.unobserve(parentElement)
49
+ }
50
+ }
51
+ }, [buttonRef, isChatOpen])
52
+
53
+ useEffect(() => {
54
+ document.documentElement.classList.toggle('wc-scroll-padding', isFixed)
55
+ document.body.classList.toggle('wc-scroll-padding', isFixed)
56
+ }, [isFixed])
57
+
58
+ let TextComponent = (<>Show Chat</>)
59
+
60
+ if (!messages.length) {
61
+ TextComponent = (<>Start Chat</>)
62
+ }
63
+
64
+ switch (availability) {
65
+ case 'AVAILABLE':
66
+ return (
67
+ <>
68
+ <div
69
+ className={classnames('wc-availability', isFixed && 'wc-availability--fixed', isChatOpen && 'wc-open')}
70
+ ref={buttonRef}
71
+ >
72
+ <div className='wc-availability__inner'>
73
+ <a
74
+ className='wc-availability__link'
75
+ href='#webchat'
76
+ draggable='false'
77
+ onClick={onClick}
78
+ onKeyUp={onKeyUp}
79
+ onKeyDown={onKeyDown}
80
+ >
81
+ {TextComponent}
82
+ </a>
83
+ </div>
84
+ </div>
85
+ {isChatOpen && <Panel />}
86
+ </>
87
+ )
88
+ case 'EXISTING':
89
+ case 'UNAVAILABLE':
90
+ return (
91
+ <p className='govuk-body'>When it is available, a 'start chat' link will appear.</p>
92
+ )
93
+ default:
94
+ return (
95
+ <p className='govuk-body'>Checking availability</p>
96
+ )
97
+ }
98
+ }
@@ -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,143 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import sanitizeHtml from 'sanitize-html'
3
+
4
+ import { PanelHeader } from '../panel/panel-header.jsx'
5
+ import { PanelFooter } from '../panel/panel-footer.jsx'
6
+ import { Message } from '../message/message.jsx'
7
+
8
+ import { useApp } from '../../store/useApp.js'
9
+ import { useTextareaAutosize } from '../../hooks/useTextareaAutosize.js'
10
+
11
+ export function Chat ({ onEndChat }) {
12
+ const { availability, thread, messages, agent, agentStatus, isAgentTyping } = useApp()
13
+
14
+ const [userMessage, setUserMessage] = useState('')
15
+
16
+ const messageRef = useRef()
17
+
18
+ useTextareaAutosize(messageRef.current, userMessage)
19
+
20
+ useEffect(() => {
21
+ if (messages.length !== 0) {
22
+ const chatBody = document.querySelector('.wc-body')
23
+ chatBody.scrollTop = chatBody.scrollHeight
24
+ }
25
+ }, [messages, isAgentTyping])
26
+
27
+ useEffect(() => {
28
+ const label = document.querySelector('.wc-form__label')
29
+
30
+ if (userMessage.length === 0) {
31
+ label.classList.remove('govuk-visually-hidden')
32
+ } else {
33
+ label.classList.add('govuk-visually-hidden')
34
+ }
35
+ }, [userMessage])
36
+
37
+ const onChange = e => {
38
+ setUserMessage(e.target?.value)
39
+ }
40
+
41
+ const agentName = agent?.nickname || agent?.firstName
42
+
43
+ let connectionHeadlineText = 'Connecting to Floodline'
44
+
45
+ if (agentStatus === 'closed') {
46
+ connectionHeadlineText = agentName ? `${agentName} ended the session` : 'Session ended by advisor'
47
+ } else if (agentStatus) {
48
+ if (agentName) {
49
+ connectionHeadlineText = `You are speaking with ${agentName}`
50
+ } else {
51
+ connectionHeadlineText = 'No advisers currently available'
52
+ }
53
+ }
54
+
55
+ if (availability === 'UNAVAILABLE') {
56
+ connectionHeadlineText = 'Webchat is not currently available'
57
+ }
58
+
59
+ const sendMessage = e => {
60
+ e.preventDefault()
61
+
62
+ if (messageRef.current.value.length === 0) {
63
+ return
64
+ }
65
+
66
+ try {
67
+ thread.sendTextMessage(sanitizeHtml(messageRef.current.value.trim()))
68
+ } catch (err) {
69
+ console.log('[Chat Error] sendMessage', err)
70
+ }
71
+
72
+ setUserMessage('')
73
+ }
74
+
75
+ return (
76
+ <>
77
+ <PanelHeader />
78
+
79
+ <div className='wc-status'>
80
+ <p className='wc-status__availability govuk-body-s'>{connectionHeadlineText}</p>
81
+ <a className='wc-status__link govuk-!-font-size-16' href='#' data-module='govuk-button' onClick={onEndChat}>End chat</a>
82
+ </div>
83
+
84
+ <div className='wc-body'>
85
+ <div className='wc-chat__body'>
86
+ <ul className='wc-chat__messages'>
87
+ {messages.length
88
+ ? messages.map((msg, index) => <Message key={msg.id} message={msg} previousMessage={messages[index - 1]} />)
89
+ : null}
90
+ {isAgentTyping
91
+ ? (
92
+ <li className='wc-chat__message govuk-body outbound'>
93
+ <div className='wc-chat__from govuk-!-font-size-14'>{agentName} is typing</div>
94
+ <div className='wc-chat__text outbound'>
95
+ <svg width='28' height='16' x='0px' y='0px' viewBox='0 0 28 16'>
96
+ <circle stroke='none' cx='3' cy='8' r='3' fill='currentColor' />
97
+ <circle stroke='none' cx='14' cy='8' r='3' fill='currentColor' />
98
+ <circle stroke='none' cx='25' cy='8' r='3' fill='currentColor' />
99
+ </svg>
100
+ </div>
101
+ </li>
102
+ )
103
+ : null}
104
+ </ul>
105
+ </div>
106
+ </div>
107
+
108
+ <PanelFooter>
109
+ <div className='wc-footer__input'>
110
+ <form className='wc-form' noValidate>
111
+ <label className='govuk-label wc-form__label govuk-!-font-size-16' htmlFor='wc-form-textarea'>
112
+ Your message<span className='govuk-visually-hidden'> (enter key submits)</span>
113
+ </label>
114
+
115
+ <textarea
116
+ ref={messageRef}
117
+ rows='1'
118
+ aria-required='true'
119
+ className='wc-form__textarea govuk-textarea govuk-!-font-size-16'
120
+ id='wc-form-textarea'
121
+ name='message'
122
+ onChange={onChange}
123
+ value={userMessage}
124
+ />
125
+
126
+ <input
127
+ type='submit'
128
+ className='wc-form__button govuk-button govuk-!-font-size-16'
129
+ value='Send'
130
+ data-prevent-double-click='true'
131
+ onClick={sendMessage}
132
+ />
133
+ </form>
134
+ </div>
135
+
136
+ <div className='wc-footer__settings'>
137
+ <a href='#' className='wc-footer__settings-link' data-module='govuk-button'>Settings</a>
138
+ <a href='#' className='wc-footer__settings-link' data-module='govuk-button' download='transcript.txt'>Save chat</a>
139
+ </div>
140
+ </PanelFooter>
141
+ </>
142
+ )
143
+ }
@@ -0,0 +1,134 @@
1
+ @keyframes blink {
2
+ 50% {
3
+ fill: transparent
4
+ }
5
+ }
6
+
7
+ .wc-status {
8
+ display: flex;
9
+ border-bottom: 1px solid $govuk-border-colour;
10
+ padding: govuk-spacing(2);
11
+
12
+ &__availability {
13
+ margin-bottom: 0;
14
+ }
15
+
16
+ &__link {
17
+ margin-left: auto;
18
+ }
19
+ }
20
+
21
+ .wc-chat {
22
+ &__messages {
23
+ overflow: scroll;
24
+ list-style: none;
25
+ padding: 0;
26
+ margin: govuk-spacing(2) 0;
27
+ }
28
+
29
+ &__message {
30
+ margin-bottom: govuk-spacing(1);
31
+
32
+ &.inbound {
33
+ text-align: right;
34
+ }
35
+ }
36
+
37
+ &__from {
38
+ color: govuk-colour('dark-grey');
39
+ margin-bottom: 0;
40
+ }
41
+
42
+ &__text {
43
+ padding: govuk-spacing(2);
44
+ margin-bottom: 0;
45
+ display: inline-block;
46
+
47
+ &.inbound {
48
+ color: govuk-colour('white');
49
+ background-color: govuk-colour('blue');
50
+ border-radius: 8px 0 8px 8px;
51
+ }
52
+
53
+ &.outbound {
54
+ background-color: govuk-colour('light-grey');
55
+ border-radius: 0 8px 8px;
56
+ }
57
+
58
+ svg {
59
+ display: block;
60
+ color: govuk-colour('dark-grey');
61
+ }
62
+
63
+ svg circle {
64
+ animation: 1s blink infinite;
65
+ fill: currentcolor;
66
+
67
+ &:nth-child(2) {
68
+ animation-delay: 250ms
69
+ }
70
+
71
+ &:nth-child(3) {
72
+ animation-delay: 500ms
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ .wc-form {
79
+ position: relative;
80
+ display: flex;
81
+ flex-direction: row;
82
+ align-items: center;
83
+ width: 100%;
84
+
85
+ &__label {
86
+ position: absolute;
87
+ top: 9px;
88
+ left: 5px;
89
+ z-index: 3;
90
+ margin-bottom: 0;
91
+ color: govuk-colour('dark-grey');
92
+ }
93
+
94
+ &__textarea {
95
+ position: relative;
96
+ width: 100%;
97
+ margin-bottom: 0;
98
+ min-height: auto;
99
+ max-height: 120px;
100
+ resize: none;
101
+ border: none;
102
+ overflow-x: hidden;
103
+ overscroll-behavior: contain;
104
+ z-index: 4;
105
+ background-color: transparent;
106
+ }
107
+
108
+ &__button {
109
+ margin: auto 0 0 govuk-spacing(3);
110
+ width: auto;
111
+ }
112
+ }
113
+
114
+ .wc-footer {
115
+ &__settings {
116
+ @include govuk-responsive-padding(2);
117
+
118
+ display: flex;
119
+ flex-direction: row;
120
+ border-top: 1px solid govuk-colour('black');
121
+
122
+ &-link {
123
+ &:first-child {
124
+ margin-right: govuk-spacing(3);
125
+ }
126
+ }
127
+ }
128
+
129
+ &__input {
130
+ @include govuk-responsive-padding(2);
131
+
132
+ border-top: 1px solid govuk-colour('black');
133
+ }
134
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react'
2
+ import { classnames } from '../../lib/classnames'
3
+
4
+ export function Message ({ message, previousMessage }) {
5
+ const outbound = message.direction === 'outbound'
6
+
7
+ const messageOwnerSameAsPrevious = message?.direction === previousMessage?.direction
8
+
9
+ return (
10
+ <li className={classnames('wc-chat__message govuk-body', outbound ? 'outbound' : 'inbound')}>
11
+ <div className='wc-chat__from govuk-!-font-size-14'>
12
+ {messageOwnerSameAsPrevious ? null : <span>{outbound ? message.assignee : 'You'}</span>}
13
+ <span className='govuk-visually-hidden'>:</span>
14
+ </div>
15
+ <p className={classnames('wc-chat__text', 'govuk-body-s', outbound ? 'outbound' : 'inbound')}>
16
+ {message.text}
17
+ </p>
18
+ </li>
19
+ )
20
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react'
2
+
3
+ export function PanelFooter ({ children }) {
4
+ return (
5
+ <div className='wc-footer'>
6
+ {children}
7
+ </div>
8
+ )
9
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react'
2
+ import { useApp } from '../../store/useApp.js'
3
+
4
+ export function PanelHeader () {
5
+ const { thread, setChatVisibility } = useApp()
6
+
7
+ const onClose = e => {
8
+ e.preventDefault()
9
+ setChatVisibility(false)
10
+ }
11
+
12
+ let ButtonComponent = (
13
+ <button className='wc-header__close' aria-label='Close the webchat' onClick={onClose}>
14
+ <svg aria-hidden='true' focusable='false' width='20' height='20' viewBox='0 0 20 20'>
15
+ <path d='M10,8.6L15.6,3L17,4.4L11.4,10L17,15.6L15.6,17L10,11.4L4.4,17L3,15.6L8.6,10L3,4.4L4.4,3L10,8.6Z' fill='currentColor' />
16
+ </svg>
17
+ </button>
18
+ )
19
+
20
+ if (thread) {
21
+ ButtonComponent = (
22
+ <button className='wc-header__hide' aria-label='Minimise the webchat' onClick={onClose}>
23
+ <svg aria-hidden='true' focusable='false' width='20' height='20' viewBox='0 0 20 20'>
24
+ <path d='M10 14.4l-7-7L4.4 6l5.6 5.6L15.6 6 17 7.4l-7 7z' fill='currentColor' />
25
+ </svg>
26
+ </button>
27
+ )
28
+ }
29
+
30
+ return (
31
+ <div className='wc-header'>
32
+ <h2 id='wc-header-title' className='wc-header__title govuk-heading-s'>
33
+ Floodline Webchat
34
+ </h2>
35
+
36
+ {ButtonComponent}
37
+ </div>
38
+ )
39
+ }