@defra/flood-webchat 0.0.1-beta.8 → 1.0.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.
Files changed (50) hide show
  1. package/README.md +89 -1
  2. package/dist/client.js +1 -754
  3. package/dist/server.js +1 -383
  4. package/main.scss +12 -8
  5. package/package.json +27 -22
  6. package/src/client/components/availability/availability.jsx +89 -54
  7. package/src/client/components/availability/availability.scss +20 -32
  8. package/src/client/components/chat/chat.jsx +239 -0
  9. package/src/client/components/chat/chat.scss +258 -0
  10. package/src/client/components/errorSummary/error-summary.jsx +34 -0
  11. package/src/client/components/live-region.jsx +55 -0
  12. package/src/client/components/message/message.jsx +21 -0
  13. package/src/client/components/panel/panel-footer.jsx +9 -0
  14. package/src/client/components/panel/panel-header.jsx +55 -15
  15. package/src/client/components/panel/panel.jsx +128 -71
  16. package/src/client/components/panel/panel.scss +63 -22
  17. package/src/client/components/screens/end-chat.jsx +77 -0
  18. package/src/client/components/screens/feedback.jsx +65 -0
  19. package/src/client/components/screens/pre-chat.jsx +50 -30
  20. package/src/client/components/screens/request-chat.jsx +177 -4
  21. package/src/client/components/screens/settings.jsx +97 -0
  22. package/src/client/components/screens/unavailable.jsx +23 -0
  23. package/src/client/components/skip-link.jsx +42 -0
  24. package/src/client/hooks/useFocusedElements.js +80 -0
  25. package/src/client/hooks/useTextareaAutosize.js +13 -0
  26. package/src/client/index.jsx +18 -1
  27. package/src/client/lib/agent-status-headline.js +17 -0
  28. package/src/client/lib/check-availability.js +1 -0
  29. package/src/client/lib/history.js +16 -0
  30. package/src/client/lib/message-notification.js +30 -0
  31. package/src/client/lib/transform-messages.js +47 -0
  32. package/src/client/scss/_objects.scss +33 -0
  33. package/src/client/scss/_settings.scss +101 -0
  34. package/src/client/scss/_tools.scss +33 -0
  35. package/src/client/scss/_utilities.scss +25 -0
  36. package/src/client/store/AppProvider.jsx +184 -0
  37. package/src/client/store/actions-map.js +153 -0
  38. package/src/client/store/constants.js +5 -0
  39. package/src/client/store/reducer.js +31 -0
  40. package/src/client/store/useApp.js +5 -0
  41. package/src/client/store/useChatSdk.js +37 -0
  42. package/src/server/index.js +15 -6
  43. package/src/server/lib/client.js +31 -37
  44. package/src/server/lib/utils.js +10 -2
  45. package/webpack.config.mjs +0 -2
  46. package/webpack.prod.mjs +7 -0
  47. package/dist/client.js.map +0 -1
  48. package/dist/server.js.map +0 -1
  49. package/src/client/lib/external-stores.js +0 -4
  50. package/src/client/lib/external-sync-store.js +0 -42
@@ -1,73 +1,130 @@
1
- import React, { useEffect, useRef, useState } from 'react'
1
+ import React, { useEffect, useRef } from 'react'
2
+
2
3
  import { classnames } from '../../lib/classnames'
3
- import { useMessageThread, useWebchatOpenState } from '../../lib/external-stores'
4
4
  import { Panel } from '../panel/panel.jsx'
5
+ import { SkipLink } from '../skip-link.jsx'
6
+
7
+ import { useApp } from '../../store/useApp'
8
+ import { historyPushState, historyReplaceState } from '../../lib/history.js'
9
+ import { LiveRegion } from '../live-region.jsx'
10
+
11
+ const { stripPageTitle } = require('../../../server/lib/utils')
12
+
13
+ export function Availability () {
14
+ const { availability, isChatOpen, setChatVisibility, unseenCount, threadId, setUnseenCount, setInstigatorId, setLiveRegionText } = useApp()
5
15
 
6
- export function Availability (props) {
7
- const [isOpen, setOpen] = useWebchatOpenState()
8
- const [isFixed, setFixed] = useState(false)
9
16
  const buttonRef = useRef()
10
- const onClick = () => {
11
- setOpen(!isOpen)
17
+
18
+ const messageText = unseenCount > 1 ? 'new messages' : 'new message'
19
+
20
+ const showUnseenCount = unseenCount > 0 && !isChatOpen
21
+
22
+ const originalTitle = stripPageTitle(document.title)
23
+
24
+ useEffect(() => {
25
+ if (showUnseenCount) {
26
+ document.title = `(${unseenCount} ${messageText}) - ${originalTitle}`
27
+ }
28
+ }, [unseenCount])
29
+
30
+ const onClick = e => {
31
+ e.preventDefault()
32
+ setUnseenCount(0)
33
+ setChatVisibility(!isChatOpen)
34
+ setInstigatorId(e.target.id)
35
+
36
+ if (!isChatOpen) {
37
+ historyPushState()
38
+ } else {
39
+ historyReplaceState()
40
+ }
12
41
  }
42
+
13
43
  const onKeyDown = event => {
14
44
  if (event.key === ' ') {
15
45
  event.preventDefault()
16
46
  }
17
47
  }
48
+
18
49
  const onKeyUp = event => {
19
50
  if (event.key === ' ') {
20
- setOpen(!isOpen)
51
+ setUnseenCount(0)
52
+ setChatVisibility(!isChatOpen)
21
53
  }
22
54
  }
23
55
 
24
- const intersectionCallback = entries => {
25
- const [entry] = entries
26
- const isBelowFold = !entry.isIntersecting && entry.boundingClientRect.top > 0
27
- setFixed(!isOpen && isBelowFold)
28
- }
29
-
30
56
  useEffect(() => {
31
- const observer = new window.IntersectionObserver(intersectionCallback, {
32
- rootMargin: '35px'
33
- })
34
- const parentElement = buttonRef.current?.parentElement
35
- if (parentElement) {
36
- observer.observe(parentElement)
57
+ if (showUnseenCount) {
58
+ setLiveRegionText(`Floodline webchat - ${unseenCount} ${messageText}`)
37
59
  }
60
+
38
61
  return () => {
39
- if (parentElement) {
40
- observer.unobserve(parentElement)
41
- }
62
+ setLiveRegionText()
42
63
  }
43
- }, [buttonRef, isOpen])
64
+ }, [unseenCount, isChatOpen])
44
65
 
45
66
  useEffect(() => {
46
- document.documentElement.classList.toggle('wc-scroll-padding', isFixed)
47
- document.body.classList.toggle('wc-scroll-padding', isFixed)
48
- }, [isFixed])
67
+ const onScroll = () => {
68
+ if (!threadId) {
69
+ return
70
+ }
71
+
72
+ if (availability !== 'AVAILABLE') {
73
+ return
74
+ }
75
+
76
+ const rect = buttonRef.current.parentElement.getBoundingClientRect()
77
+
78
+ const isBelowFold = rect.top + 35 > (window.innerHeight || document.documentElement.clientHeight)
79
+ const isFixed = !isChatOpen && isBelowFold
80
+
81
+ document.documentElement.classList.toggle('wc-u-scroll-padding', isFixed)
82
+ document.body.classList.toggle('wc-u-scroll-padding', isFixed)
83
+ buttonRef.current.classList.toggle('wc-availability--fixed', isFixed)
84
+ }
85
+
86
+ document.addEventListener('scroll', onScroll)
87
+ onScroll()
49
88
 
50
- switch (props.availability) {
89
+ return () => {
90
+ document.removeEventListener('scroll', onScroll)
91
+ }
92
+ }, [threadId, isChatOpen, availability])
93
+
94
+ switch (availability) {
51
95
  case 'AVAILABLE':
52
96
  return (
53
97
  <>
54
98
  <div
55
- className={classnames('wc-availability', isFixed && 'wc-availability--fixed')}
99
+ className={classnames('wc-availability', isChatOpen && 'wc-open')}
56
100
  ref={buttonRef}
57
101
  >
58
102
  <div className='wc-availability__inner'>
59
103
  <a
104
+ id='webchat-start-chat-link'
60
105
  className='wc-availability__link'
61
- href='#webchat' role='button' draggable='false'
106
+ href='#webchat'
107
+ draggable='false'
62
108
  onClick={onClick}
63
109
  onKeyUp={onKeyUp}
64
110
  onKeyDown={onKeyDown}
111
+ role='button'
112
+ data-module='govuk-button'
65
113
  >
66
- <AvailabilityContent />
114
+ {!threadId ? <>Start chat</> : <>Show chat</>}
115
+ {showUnseenCount
116
+ ? (
117
+ <span className='wc-availability__unseen'>
118
+ {unseenCount}
119
+ <span className='govuk-visually-hidden'> {messageText}</span>
120
+ </span>
121
+ )
122
+ : null}
67
123
  </a>
68
124
  </div>
69
125
  </div>
70
- {isOpen && <Panel screenNumber={0} onClose={onClick} />}
126
+ {!isChatOpen ? <LiveRegion /> : <Panel />}
127
+ <SkipLink />
71
128
  </>
72
129
  )
73
130
  case 'EXISTING':
@@ -81,25 +138,3 @@ export function Availability (props) {
81
138
  )
82
139
  }
83
140
  }
84
-
85
- function AvailabilityContent () {
86
- const [thread] = useMessageThread()
87
- const unreadMessageCount = thread.filter(message => !message.read).length
88
- if (!thread.length) {
89
- return (
90
- <>
91
- Start Chat
92
- </>
93
- )
94
- }
95
- return (
96
- <>
97
- Show Chat {!!unreadMessageCount && (
98
- <>
99
- <span className='wc-availability__unseen'>{unreadMessageCount}</span>
100
- <span className='govuk-visually-hidden'> {unreadMessageCount === 1 ? 'new message' : 'new messages'}</span>
101
- </>
102
- )}
103
- </>
104
- )
105
- }
@@ -1,3 +1,8 @@
1
+ // stylelint-disable selector-max-id
2
+ #wc-availability {
3
+ margin-bottom: govuk-spacing(4);
4
+ }
5
+ // stylelint-enable selector-max-id
1
6
 
2
7
  .wc-availability__inner {
3
8
  max-width: 960px;
@@ -12,6 +17,10 @@
12
17
  right: 0;
13
18
  padding-left: 15px;
14
19
  padding-right: 15px;
20
+ border: 1px solid transparent;
21
+ @media (forced-colors: active) {
22
+ border-color: currentColor;
23
+ }
15
24
 
16
25
  @include mq ($from: 'tablet') {
17
26
  padding-left: 30px;
@@ -27,52 +36,31 @@
27
36
  }
28
37
 
29
38
  .wc-availability__link {
39
+ @extend %govuk-link;
40
+ display: inline-block;
30
41
  @include govuk-font($size: 19);
31
- position:relative;
32
- display:inline-block;
33
42
  margin-right: 5px;
34
- margin-bottom: 0;
35
- fill: govuk-colour('black');
36
43
 
37
- @include mq ($from: tablet) {
38
- margin-right: 10px;
44
+ &:visited:not(:hover):not(:focus) {
45
+ color: govuk-colour('blue');
39
46
  }
40
47
 
41
48
  .govuk-visually-hidden, .wc-availability__unseen {
42
49
  pointer-events: none;
43
50
  }
44
51
 
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
- }
52
+ &:hover:not(:focus):not(:active) .wc-availability__unseen {
53
+ background-color: govuk-colour('dark-blue');
70
54
  }
71
55
  }
72
56
 
73
57
  .wc-availability__unseen {
74
58
  display: inline-block;
75
59
  position: relative;
60
+ border: 1px solid transparent;
61
+ @media (forced-colors: active) {
62
+ border-color: currentColor;
63
+ }
76
64
  border-radius: 10px;
77
65
  background-color: govuk-colour('black');
78
66
  color: govuk-colour('white');
@@ -86,4 +74,4 @@
86
74
  padding-left: 7px;
87
75
  padding-right: 7px;
88
76
  }
89
- }
77
+ }
@@ -0,0 +1,239 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+
3
+ import { TYPING_INDICATOR_DURATION } from '../../store/constants.js'
4
+
5
+ import { PanelHeader } from '../panel/panel-header.jsx'
6
+ import { PanelFooter } from '../panel/panel-footer.jsx'
7
+ import { Message } from '../message/message.jsx'
8
+
9
+ import { useApp } from '../../store/useApp.js'
10
+ import { useTextareaAutosize } from '../../hooks/useTextareaAutosize.js'
11
+ import { formatTranscript } from '../../lib/transform-messages.js'
12
+ import { agentStatusHeadline } from '../../lib/agent-status-headline.js'
13
+
14
+ export function Chat ({ onEndChatScreen, onSettingsScreen }) {
15
+ const { availability, thread, messages, agent, agentStatus, isAgentTyping, isChatOpen, settings, isKeyboard, setLiveRegionText } = useApp()
16
+
17
+ const [userMessage, setUserMessage] = useState('')
18
+ const [focusVisibleWithin, setFocusVisibleWithin] = useState(false)
19
+
20
+ const messageRef = useRef()
21
+
22
+ useTextareaAutosize(messageRef.current, userMessage)
23
+
24
+ useEffect(() => {
25
+ if (settings.scroll && messages.length !== 0) {
26
+ const chatBody = document.querySelector('.wc-body')
27
+ chatBody.scrollTop = chatBody.scrollHeight
28
+ }
29
+ }, [messages, isAgentTyping])
30
+
31
+ useEffect(() => {
32
+ const label = document.querySelector('.wc-form__label')
33
+
34
+ if (userMessage.length === 0) {
35
+ label.classList.remove('govuk-visually-hidden')
36
+ } else {
37
+ label.classList.add('govuk-visually-hidden')
38
+ }
39
+ }, [userMessage])
40
+
41
+ const onChange = e => {
42
+ setUserMessage(e.target?.value)
43
+ }
44
+
45
+ const agentName = agent?.nickname || agent?.firstName
46
+
47
+ const connectionHeadlineText = agentStatusHeadline(availability, agentStatus, agentName)
48
+
49
+ useEffect(() => {
50
+ if (connectionHeadlineText) {
51
+ setLiveRegionText(`Floodline webchat - ${connectionHeadlineText}`)
52
+ }
53
+
54
+ return () => {
55
+ setLiveRegionText()
56
+ }
57
+ }, [connectionHeadlineText])
58
+
59
+ useEffect(() => {
60
+ if (isAgentTyping && isChatOpen) {
61
+ setLiveRegionText(`${agentName} is typing...`)
62
+ }
63
+
64
+ return () => {
65
+ setLiveRegionText()
66
+ }
67
+ }, [isAgentTyping, isChatOpen])
68
+
69
+ useEffect(() => {
70
+ const lastAgentMessage = messages[messages.length - 1]
71
+
72
+ if (agentStatus !== 'closed' && lastAgentMessage?.direction === 'outbound') {
73
+ setLiveRegionText(`${agentName} said: ${lastAgentMessage.text}`)
74
+ }
75
+
76
+ return () => {
77
+ setLiveRegionText()
78
+ }
79
+ }, [messages])
80
+
81
+ const sendMessage = () => {
82
+ if (messageRef.current.value.length === 0 || agentStatus === 'closed') {
83
+ return
84
+ }
85
+
86
+ try {
87
+ const message = messageRef.current.value.trim()
88
+ thread.sendTextMessage(message)
89
+ setLiveRegionText(`You said: ${message}`)
90
+ } catch (err) {
91
+ console.log('[Chat Error] sendMessage', err)
92
+ }
93
+
94
+ setUserMessage('')
95
+ }
96
+
97
+ const handleSubmit = e => {
98
+ e.preventDefault()
99
+ sendMessage()
100
+ }
101
+
102
+ const saveChat = () => {
103
+ const transcript = formatTranscript(messages)
104
+
105
+ const saveChatLink = document.querySelector('#transcript-download')
106
+
107
+ saveChatLink.setAttribute('href', `data:text/plain;charset=utf-8,${transcript}`)
108
+ }
109
+
110
+ const handleKeyPress = e => {
111
+ if (e.key === 'Enter' && !e.shiftKey) {
112
+ switch (e.target.id) {
113
+ case 'text-area':
114
+ e.preventDefault()
115
+ sendMessage()
116
+ break
117
+ case 'end-chat':
118
+ onEndChatScreen(e)
119
+ break
120
+ case 'wc-settings':
121
+ onSettingsScreen(e)
122
+ break
123
+ case 'transcript-download':
124
+ saveChat()
125
+ break
126
+ default:
127
+ break
128
+ }
129
+ } else if (e.key === ' ') {
130
+ if (e.target.id === 'end-chat') {
131
+ onEndChatScreen(e)
132
+ }
133
+ if (e.target.id === 'wc-settings') {
134
+ onSettingsScreen(e)
135
+ }
136
+ } else {
137
+ thread.keystroke()
138
+ setTimeout(() => {
139
+ thread.stopTyping()
140
+ }, TYPING_INDICATOR_DURATION)
141
+ }
142
+ }
143
+
144
+ return (
145
+ <>
146
+ <PanelHeader />
147
+
148
+ <div className='wc-status'>
149
+ <p className='wc-status__availability'>{connectionHeadlineText}</p>
150
+ <a
151
+ id='end-chat'
152
+ className='wc-status__link'
153
+ href='#'
154
+ data-module='govuk-button'
155
+ role='button'
156
+ onClick={onEndChatScreen}
157
+ onKeyDown={handleKeyPress}
158
+ >
159
+ End chat
160
+ </a>
161
+ </div>
162
+
163
+ <div className='wc-body' tabIndex='0'>
164
+ <ul className='wc-chat'>
165
+ {messages.length
166
+ ? messages.map((msg, index) => <Message key={msg.id} message={msg} previousMessage={messages[index - 1]} />)
167
+ : null}
168
+ {isAgentTyping
169
+ ? (
170
+ <li className='wc-chat__message outbound'>
171
+ <div className='wc-chat__from'>{agentName} is typing</div>
172
+ <div className='wc-chat__text outbound'>
173
+ <svg width='28' height='16' x='0px' y='0px' viewBox='0 0 28 16'>
174
+ <circle stroke='none' cx='3' cy='8' r='3' fill='currentColor' />
175
+ <circle stroke='none' cx='14' cy='8' r='3' fill='currentColor' />
176
+ <circle stroke='none' cx='25' cy='8' r='3' fill='currentColor' />
177
+ </svg>
178
+ </div>
179
+ </li>
180
+ )
181
+ : null}
182
+ </ul>
183
+ </div>
184
+
185
+ <PanelFooter>
186
+ <form className={`wc-form${focusVisibleWithin ? ' wc-focus-within' : ''}`} noValidate onSubmit={handleSubmit}>
187
+ <label className='govuk-label wc-form__label' htmlFor='wc-form-textarea'>
188
+ Your message<span className='govuk-visually-hidden'> (enter key submits)</span>
189
+ </label>
190
+
191
+ <textarea
192
+ ref={messageRef}
193
+ rows='1'
194
+ aria-required='true'
195
+ className='wc-form__textarea'
196
+ id='text-area'
197
+ name='message'
198
+ onChange={onChange}
199
+ onKeyDown={handleKeyPress}
200
+ onFocus={() => { setFocusVisibleWithin(isKeyboard) }}
201
+ onBlur={() => { setFocusVisibleWithin(false) }}
202
+ value={userMessage}
203
+ />
204
+
205
+ <input
206
+ type='submit'
207
+ className='wc-form__button govuk-button'
208
+ value='Send'
209
+ data-prevent-double-click='true'
210
+ />
211
+ </form>
212
+
213
+ <div className='wc-footer__settings'>
214
+ <a
215
+ href='#'
216
+ id='wc-settings'
217
+ className='wc-footer__settings-link'
218
+ data-module='govuk-button'
219
+ onKeyDown={handleKeyPress}
220
+ onClick={onSettingsScreen}
221
+ >
222
+ Settings
223
+ </a>
224
+ <a
225
+ href='#'
226
+ id='transcript-download'
227
+ className='wc-footer__settings-link'
228
+ data-module='govuk-button'
229
+ download='floodline-webchat-transcript.txt'
230
+ onKeyDown={handleKeyPress}
231
+ onClick={saveChat}
232
+ >
233
+ Save chat
234
+ </a>
235
+ </div>
236
+ </PanelFooter>
237
+ </>
238
+ )
239
+ }