@hellotext/hellotext 2.3.9 → 2.4.0

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 (39) hide show
  1. package/dist/hellotext.js +1 -1
  2. package/dist/webchat-emoji-en.js +1 -0
  3. package/dist/webchat-emoji-es.js +1 -0
  4. package/dist/webchat-emoji.js +1 -0
  5. package/index.d.ts +30 -4
  6. package/lib/api/webchats.cjs +20 -0
  7. package/lib/api/webchats.js +20 -0
  8. package/lib/controllers/mixins/usePopover.cjs +7 -2
  9. package/lib/controllers/mixins/usePopover.js +7 -2
  10. package/lib/controllers/webchat/emoji_picker_controller.cjs +49 -10
  11. package/lib/controllers/webchat/emoji_picker_controller.js +66 -8
  12. package/lib/controllers/webchat/useBehaviour.cjs +74 -0
  13. package/lib/controllers/webchat/useBehaviour.js +67 -0
  14. package/lib/controllers/webchat/useOpeningSequence.cjs +103 -0
  15. package/lib/controllers/webchat/useOpeningSequence.js +96 -0
  16. package/lib/controllers/webchat/useTeaser.cjs +163 -0
  17. package/lib/controllers/webchat/useTeaser.js +156 -0
  18. package/lib/controllers/webchat_controller.cjs +165 -22
  19. package/lib/controllers/webchat_controller.js +173 -24
  20. package/lib/core/configuration/webchat.cjs +117 -42
  21. package/lib/core/configuration/webchat.js +121 -43
  22. package/lib/hellotext.cjs +28 -4
  23. package/lib/hellotext.js +28 -1
  24. package/lib/models/business.cjs +2 -0
  25. package/lib/models/business.js +2 -0
  26. package/lib/models/webchat.cjs +25 -0
  27. package/lib/models/webchat.js +25 -0
  28. package/package.json +1 -1
  29. package/src/api/webchats.js +17 -0
  30. package/src/controllers/mixins/usePopover.js +4 -2
  31. package/src/controllers/webchat/emoji_picker_controller.js +51 -9
  32. package/src/controllers/webchat/useBehaviour.js +81 -0
  33. package/src/controllers/webchat/useOpeningSequence.js +127 -0
  34. package/src/controllers/webchat/useTeaser.js +192 -0
  35. package/src/controllers/webchat_controller.js +187 -23
  36. package/src/core/configuration/webchat.js +127 -43
  37. package/src/hellotext.js +32 -4
  38. package/src/models/business.js +2 -0
  39. package/src/models/webchat.js +28 -0
@@ -6,10 +6,12 @@ import WebchatChannel from '../channels/webchat_channel'
6
6
  import Hellotext from '../hellotext'
7
7
 
8
8
  import { Locale } from '../core'
9
- import { Webchat as WebchatConfiguration, behaviors } from '../core/configuration/webchat'
9
+ import { Webchat as WebchatConfiguration, modes } from '../core/configuration/webchat'
10
10
 
11
- import { LogoBuilder } from '../builders/logo_builder'
12
11
  import { usePopover } from './mixins/usePopover'
12
+ import { useBehaviour } from './webchat/useBehaviour'
13
+ import { useOpeningSequence } from './webchat/useOpeningSequence'
14
+ import { useTeaser } from './webchat/useTeaser'
13
15
 
14
16
  export default class extends Controller {
15
17
  static values = {
@@ -26,7 +28,10 @@ export default class extends Controller {
26
28
  typingIndicatorKeepAlive: { type: Number, default: 30000 }, // 30 seconds
27
29
  offset: { type: Number, default: 24 },
28
30
  padding: { type: Number, default: 24 },
29
- optimisticTypingIndicatorWait: { type: Number, default: 1000 }, // 1 second
31
+ optimisticTypingIndicatorWait: { type: Number, default: 1000 }, // 1 second,
32
+ teaser: Object,
33
+ messageTeaser: String,
34
+ behaviour: Object,
30
35
  }
31
36
 
32
37
  static classes = ['fadeOut']
@@ -51,6 +56,9 @@ export default class extends Controller {
51
56
  'unreadCounter',
52
57
  'typingIndicator',
53
58
  'typingIndicatorTemplate',
59
+ 'teaser',
60
+ 'openingSequence',
61
+ 'openingSequenceMessage',
54
62
  ]
55
63
 
56
64
  initialize() {
@@ -63,6 +71,7 @@ export default class extends Controller {
63
71
  )
64
72
 
65
73
  this.files = []
74
+ this.messageIds = new Set()
66
75
 
67
76
  this.onMessageReceived = this.onMessageReceived.bind(this)
68
77
  this.onMessageReaction = this.onMessageReaction.bind(this)
@@ -77,13 +86,24 @@ export default class extends Controller {
77
86
  }
78
87
 
79
88
  connect() {
89
+ useBehaviour(this)
80
90
  usePopover(this)
81
-
82
- this.popoverTarget.classList.add(...WebchatConfiguration.classes)
83
- this.triggerTarget.classList.add(...WebchatConfiguration.triggerClasses)
91
+ useOpeningSequence(this)
92
+ useTeaser(this)
84
93
 
85
94
  this.setupFloatingUI({ trigger: this.triggerTarget, popover: this.popoverTarget })
86
95
 
96
+ if (this.hasTeaserTarget) {
97
+ this.setupFloatingUI({
98
+ trigger: this.triggerTarget,
99
+ popover: this.teaserTarget,
100
+ strategy: 'absolute',
101
+ })
102
+ }
103
+
104
+ this.setupTeaser()
105
+ this.setupOpeningSequence()
106
+
87
107
  this.webChatChannel.onMessage(this.onMessageReceived)
88
108
  this.webChatChannel.onTypingStart(this.onTypingStart)
89
109
 
@@ -91,21 +111,22 @@ export default class extends Controller {
91
111
 
92
112
  this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
93
113
 
94
- if (!Hellotext.business.features.white_label) {
95
- this.toolbarTarget.appendChild(LogoBuilder.build())
96
- }
97
-
98
114
  if (this.shouldOpenOnMount) {
99
115
  this.openValue = true
100
116
  }
101
117
 
102
118
  Hellotext.eventEmitter.dispatch('webchat:mounted')
103
119
  this.broadcastChannel.addEventListener('message', this.onOutboundMessageSent)
120
+ this.scheduleBehaviourOpen()
104
121
 
105
122
  super.connect()
106
123
  }
107
124
 
108
125
  disconnect() {
126
+ this.cancelBehaviourOpen()
127
+ this.teardownTeaser()
128
+ this.teardownOpeningSequence()
129
+
109
130
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
110
131
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
111
132
 
@@ -273,7 +294,7 @@ export default class extends Controller {
273
294
  image.src = attachmentUrl
274
295
  image.style.display = 'block'
275
296
 
276
- element.querySelector('[data-attachment-container]').appendChild(image)
297
+ this.messageAttachmentsContainer(element)?.appendChild(image)
277
298
  })
278
299
  }
279
300
 
@@ -291,7 +312,7 @@ export default class extends Controller {
291
312
 
292
313
  onClickOutside(event) {
293
314
  if (
294
- WebchatConfiguration.behaviour === behaviors.POPOVER &&
315
+ WebchatConfiguration.mode === modes.POPOVER &&
295
316
  this.openValue &&
296
317
  event.target.nodeType &&
297
318
  this.element.contains(event.target) === false
@@ -310,6 +331,7 @@ export default class extends Controller {
310
331
 
311
332
  onPopoverOpened() {
312
333
  this.popoverTarget.classList.remove(...this.fadeOutClasses)
334
+ this.dismissTeaserForSession?.()
313
335
 
314
336
  if (!this.onMobile) {
315
337
  this.inputTarget.focus()
@@ -329,6 +351,12 @@ export default class extends Controller {
329
351
  Hellotext.eventEmitter.dispatch('webchat:opened')
330
352
  localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'opened')
331
353
 
354
+ if (this.messageTeaserValue) {
355
+ this.messageTeaserValue = null
356
+ }
357
+
358
+ this.startOpeningSequence()
359
+
332
360
  if (this.unreadCounterTarget.style.display === 'none') return
333
361
 
334
362
  this.unreadCounterTarget.style.display = 'none'
@@ -367,7 +395,11 @@ export default class extends Controller {
367
395
  }
368
396
 
369
397
  onMessageReceived(message) {
370
- const { id, body, attachments } = message
398
+ const { id, body, attachments, teaser } = message
399
+
400
+ if (!this.claimMessageId(id)) return
401
+
402
+ this.hideTeaser?.()
371
403
 
372
404
  if (message.carousel) {
373
405
  return this.insertCarouselMessage(message)
@@ -380,6 +412,8 @@ export default class extends Controller {
380
412
  element.style.display = 'flex'
381
413
 
382
414
  element.querySelector('[data-body]').innerHTML = div.innerHTML
415
+
416
+ element.setAttribute('data-id', id)
383
417
  element.setAttribute('data-hellotext--webchat-target', 'message')
384
418
 
385
419
  if (attachments) {
@@ -388,7 +422,7 @@ export default class extends Controller {
388
422
  image.src = attachmentUrl
389
423
  image.style.display = 'block'
390
424
 
391
- element.querySelector('[data-attachment-container]').appendChild(image)
425
+ this.messageAttachmentsContainer(element)?.appendChild(image)
392
426
  })
393
427
  }
394
428
 
@@ -402,21 +436,45 @@ export default class extends Controller {
402
436
 
403
437
  element.scrollIntoView({ behavior: 'smooth' })
404
438
 
439
+ this.updateMessageTeaser(teaser)
440
+
405
441
  if (this.openValue) {
406
442
  this.messagesAPI.markAsSeen(id)
407
443
  return
408
444
  }
409
445
 
410
- this.unreadCounterTarget.style.display = 'flex'
446
+ this.incrementUnreadCounter()
447
+ }
411
448
 
412
- const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
413
- this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
449
+ // TCP broadcasts may deliver the same incoming message twice before Stimulus
450
+ // has connected the appended message target. Claiming the id in memory closes
451
+ // that window, while the target check still drops messages rendered earlier.
452
+ claimMessageId(id) {
453
+ const messageTargets = this.messageTargets || []
454
+
455
+ if (this.messageIds.has(id)) return false
456
+
457
+ this.messageIds.add(id)
458
+
459
+ return !messageTargets.some(element => element.dataset.id === id)
460
+ }
461
+
462
+ updateMessageTeaser(teaser) {
463
+ this.messageTeaserValue = teaser
464
+
465
+ if (this.messageTeaserValue && this.hasTeaserTarget) {
466
+ this.teaserTarget.innerHTML = this.messageTeaserValue
467
+ this.teaserTarget.classList.toggle('invisible', this.openValue)
468
+ }
414
469
  }
415
470
 
416
471
  insertCarouselMessage(message) {
417
472
  const html = message.html
418
473
  const element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild
419
474
 
475
+ element.setAttribute('data-id', message.id)
476
+ element.setAttribute('data-hellotext--webchat-target', 'message')
477
+
420
478
  this.clearTypingIndicator()
421
479
  this.messagesContainerTarget.appendChild(element)
422
480
 
@@ -427,14 +485,14 @@ export default class extends Controller {
427
485
  body: element.querySelector('[data-body]')?.innerText || '',
428
486
  })
429
487
 
488
+ this.updateMessageTeaser(message.teaser)
489
+
430
490
  if (this.openValue) {
431
491
  this.messagesAPI.markAsSeen(message.id)
432
492
  return
433
493
  }
434
494
 
435
- this.unreadCounterTarget.style.display = 'flex'
436
- const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
437
- this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
495
+ this.incrementUnreadCounter()
438
496
  }
439
497
 
440
498
  resizeInput() {
@@ -451,6 +509,8 @@ export default class extends Controller {
451
509
  }
452
510
 
453
511
  async sendQuickReplyMessage({ detail: { id, product, buttonId, body, cardElement } }) {
512
+ this.dismissTeaserForSession?.()
513
+
454
514
  const formData = new FormData()
455
515
 
456
516
  formData.append('message[body]', body)
@@ -460,6 +520,7 @@ export default class extends Controller {
460
520
 
461
521
  formData.append('session', Hellotext.session)
462
522
  formData.append('locale', Locale.toString())
523
+ this.appendOpeningSequenceMessageIds(formData)
463
524
 
464
525
  const element = this.buildMessageElement()
465
526
  const attachment = cardElement.querySelector('img')?.cloneNode(true)
@@ -470,7 +531,7 @@ export default class extends Controller {
470
531
  attachment.removeAttribute('width')
471
532
  attachment.removeAttribute('height')
472
533
 
473
- element.querySelector('[data-attachment-container]').appendChild(attachment)
534
+ this.messageAttachmentsContainer(element)?.appendChild(attachment)
474
535
  }
475
536
 
476
537
  if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
@@ -503,6 +564,7 @@ export default class extends Controller {
503
564
  const data = await response.json()
504
565
 
505
566
  this.dispatch('set:id', { target: element, detail: data.id })
567
+ this.clearRevealedOpeningSequenceMessageIds()
506
568
 
507
569
  const message = {
508
570
  id: data.id,
@@ -517,9 +579,94 @@ export default class extends Controller {
517
579
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
518
580
  }
519
581
 
520
- async sendMessage(e) {
582
+ async sendTeaserQuickReply(event) {
583
+ event.preventDefault()
584
+ event.stopPropagation()
585
+
586
+ const button = event.currentTarget
587
+ const value = (button.dataset.value || '').trim()
588
+ const label = [button.dataset.text, button.textContent]
589
+ .map(text => (text || '').trim())
590
+ .find(text => text.length > 0)
591
+ const text = value || label
592
+
593
+ if (!text) return
594
+
595
+ this.dismissTeaserForSession?.()
596
+ this.show()
597
+
598
+ const buttonType = (button.dataset.type || '').trim() || 'quick_reply'
521
599
  const formData = new FormData()
522
600
 
601
+ formData.append('message[body]', text)
602
+ formData.append('session', Hellotext.session)
603
+ formData.append('locale', Locale.toString())
604
+ this.appendOpeningSequenceMessageIds(formData)
605
+
606
+ const element = this.buildMessageElement()
607
+
608
+ element.querySelector('[data-body]').innerText = text
609
+
610
+ if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
611
+ this.messagesContainerTarget.insertBefore(element, this.typingIndicatorTarget)
612
+ } else {
613
+ this.messagesContainerTarget.appendChild(element)
614
+ }
615
+
616
+ element.scrollIntoView({ behavior: 'smooth' })
617
+
618
+ this.broadcastChannel.postMessage({
619
+ type: 'message:sent',
620
+ element: element.outerHTML,
621
+ })
622
+
623
+ if (!this.typingIndicatorVisible) {
624
+ clearTimeout(this.optimisticTypingTimeout)
625
+ this.optimisticTypingTimeout = setTimeout(() => {
626
+ this.showOptimisticTypingIndicator()
627
+ }, this.optimisticTypingIndicatorWaitValue)
628
+ }
629
+
630
+ const response = await this.messagesAPI.create(formData)
631
+
632
+ if (response.failed) {
633
+ clearTimeout(this.optimisticTypingTimeout)
634
+
635
+ this.broadcastChannel.postMessage({
636
+ type: 'message:failed',
637
+ id: element.id,
638
+ })
639
+
640
+ return element.classList.add('failed')
641
+ }
642
+
643
+ const data = await response.json()
644
+ element.setAttribute('data-id', data.id)
645
+ this.clearRevealedOpeningSequenceMessageIds()
646
+
647
+ Hellotext.eventEmitter.dispatch('webchat:message:sent', {
648
+ id: data.id,
649
+ body: text,
650
+ attachments: [],
651
+ type: 'quick_reply',
652
+ teaser: {
653
+ text: label || text,
654
+ value: value || text,
655
+ type: buttonType,
656
+ },
657
+ })
658
+
659
+ if (data.conversation && data.conversation !== this.conversationIdValue) {
660
+ this.conversationIdValue = data.conversation
661
+ this.webChatChannel.updateSubscriptionWith(this.conversationIdValue)
662
+ }
663
+
664
+ if (this.typingIndicatorVisible) {
665
+ this.resetTypingIndicatorTimer()
666
+ }
667
+ }
668
+
669
+ async sendMessage(e) {
523
670
  const message = {
524
671
  body: this.inputTarget.value,
525
672
  attachments: this.files,
@@ -533,6 +680,10 @@ export default class extends Controller {
533
680
  return
534
681
  }
535
682
 
683
+ this.dismissTeaserForSession?.()
684
+
685
+ const formData = new FormData()
686
+
536
687
  if (this.inputTarget.value.trim().length > 0) {
537
688
  formData.append('message[body]', this.inputTarget.value)
538
689
  } else {
@@ -545,6 +696,7 @@ export default class extends Controller {
545
696
 
546
697
  formData.append('session', Hellotext.session)
547
698
  formData.append('locale', Locale.toString())
699
+ this.appendOpeningSequenceMessageIds(formData)
548
700
 
549
701
  const element = this.buildMessageElement()
550
702
 
@@ -558,7 +710,7 @@ export default class extends Controller {
558
710
 
559
711
  if (attachments.length > 0) {
560
712
  attachments.forEach(attachment => {
561
- element.querySelector('[data-attachment-container]').appendChild(attachment.cloneNode(true))
713
+ this.messageAttachmentsContainer(element)?.appendChild(attachment.cloneNode(true))
562
714
  })
563
715
  }
564
716
 
@@ -614,6 +766,7 @@ export default class extends Controller {
614
766
  const data = await response.json()
615
767
  element.setAttribute('data-id', data.id)
616
768
  message.id = data.id
769
+ this.clearRevealedOpeningSequenceMessageIds()
617
770
 
618
771
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
619
772
 
@@ -644,6 +797,17 @@ export default class extends Controller {
644
797
  return element
645
798
  }
646
799
 
800
+ messageAttachmentsContainer(element) {
801
+ return element.querySelector('[data-attachments-container], [data-attachment-container]')
802
+ }
803
+
804
+ incrementUnreadCounter() {
805
+ this.unreadCounterTarget.style.display = 'flex'
806
+
807
+ const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
808
+ this.unreadCounterTarget.innerText = Math.min(unreadCount, 9)
809
+ }
810
+
647
811
  openAttachment() {
648
812
  this.attachmentInputTarget.click()
649
813
  }
@@ -28,13 +28,13 @@ const strategies = {
28
28
  }
29
29
 
30
30
  /**
31
- * @typedef {'modal' | 'popover'} Behaviour
31
+ * @typedef {'modal' | 'popover'} Mode
32
32
  */
33
33
 
34
34
  /**
35
- * @enum {Behaviour}
35
+ * @enum {Mode}
36
36
  */
37
- const behaviors = {
37
+ const modes = {
38
38
  MODAL: 'modal',
39
39
  POPOVER: 'popover',
40
40
  }
@@ -46,6 +46,20 @@ const behaviors = {
46
46
  * @property {string} typography - The typography style (e.g., font family).
47
47
  */
48
48
 
49
+ /**
50
+ * @typedef {Object} Appearance
51
+ * @property {Object} [header] - Header appearance overrides.
52
+ * @property {string} [header.name] - Business name shown in the webchat header.
53
+ * @property {Object} [launcher] - Launcher appearance overrides.
54
+ * @property {string} [launcher.iconUrl] - Image URL used for the launcher icon.
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} WhatsApp
59
+ * @property {string} [number] - WhatsApp number used by the handoff configuration.
60
+ * @property {boolean} [restrictToChannel] - Whether handoff should be restricted to the configured channel.
61
+ */
62
+
49
63
  /**
50
64
  * @class Webchat
51
65
  * @classdesc
@@ -53,10 +67,11 @@ const behaviors = {
53
67
  * @property {String} id - the id of the webchat.
54
68
  * @property {String} container - the container to append the webchat to, defaults to 'body'
55
69
  * @property {Placement} placement - the placement of the webchat within the container, defaults to "bottom-right".
56
- * @property {String} classes - additional classes to apply to the webchat popup.
57
- * @property {String} triggerClasses - additional classes to apply to the webchat popup trigger.
58
- * @property {Behaviour} behaviour - the behaviour of the webchat, defaults to 'popover'.
70
+ * @property {Mode} mode - how the webchat behaves while open, defaults to 'popover'.
71
+ * @property {Object} behaviour - runtime opening behaviour for the webchat.
59
72
  * @property {Style} style - the style of the webchat.
73
+ * @property {Appearance} appearance - appearance overrides.
74
+ * @property {WhatsApp} whatsapp - WhatsApp handoff overrides.
60
75
  * @property {Strategy} strategy - the strategy used to position the webchat. Defaults to 'absolute'
61
76
  */
62
77
 
@@ -64,10 +79,12 @@ class Webchat {
64
79
  static _id
65
80
  static _container = 'body'
66
81
  static _placement = 'bottom-right'
67
- static _classes = []
68
- static _triggerClasses = []
69
82
  static _style = {}
70
- static _behaviour = behaviors.POPOVER
83
+ static _appearance = {}
84
+ static _whatsapp = {}
85
+ static _mode = modes.POPOVER
86
+ static _behaviour = null
87
+ static _hasBehaviourOverride = false
71
88
  static _strategy = null
72
89
 
73
90
  static set container(value) {
@@ -90,38 +107,6 @@ class Webchat {
90
107
  return this._placement
91
108
  }
92
109
 
93
- static set classes(value) {
94
- if (!Array.isArray(value) && typeof value !== 'string') {
95
- throw new Error('classes must be an array or a string')
96
- }
97
-
98
- this._classes = value
99
- }
100
-
101
- static get classes() {
102
- if (typeof this._classes === 'string') {
103
- return this._classes.split(',').map(c => c.trim())
104
- } else {
105
- return this._classes
106
- }
107
- }
108
-
109
- static set triggerClasses(value) {
110
- if (!Array.isArray(value) && typeof value !== 'string') {
111
- throw new Error('triggerClasses must be an array or a string')
112
- }
113
-
114
- this._triggerClasses = value
115
- }
116
-
117
- static get triggerClasses() {
118
- if (typeof this._triggerClasses === 'string') {
119
- return this._triggerClasses.split(',').map(c => c.trim())
120
- } else {
121
- return this._triggerClasses
122
- }
123
- }
124
-
125
110
  static set id(value) {
126
111
  this._id = value
127
112
  }
@@ -160,18 +145,113 @@ class Webchat {
160
145
  this._style = value
161
146
  }
162
147
 
148
+ static get appearance() {
149
+ return this._appearance
150
+ }
151
+
152
+ static set appearance(value) {
153
+ if (!this.isPlainObject(value)) {
154
+ throw new Error('Appearance must be an object')
155
+ }
156
+
157
+ Object.entries(value).forEach(([key, nestedValue]) => {
158
+ if (!['header', 'launcher'].includes(key)) {
159
+ throw new Error(`Invalid appearance property: ${key}`)
160
+ }
161
+
162
+ if (!this.isPlainObject(nestedValue)) {
163
+ throw new Error(`Appearance ${key} must be an object`)
164
+ }
165
+
166
+ Object.entries(nestedValue).forEach(([nestedKey, propertyValue]) => {
167
+ if (key === 'header' && nestedKey !== 'name') {
168
+ throw new Error(`Invalid appearance header property: ${nestedKey}`)
169
+ }
170
+
171
+ if (key === 'launcher' && nestedKey !== 'iconUrl') {
172
+ throw new Error(`Invalid appearance launcher property: ${nestedKey}`)
173
+ }
174
+
175
+ if (propertyValue == null) {
176
+ return
177
+ }
178
+
179
+ if (typeof propertyValue !== 'string') {
180
+ throw new Error(`Invalid appearance ${key}.${nestedKey} value: ${propertyValue}`)
181
+ }
182
+ })
183
+ })
184
+
185
+ this._appearance = value
186
+ }
187
+
188
+ static get whatsapp() {
189
+ return this._whatsapp
190
+ }
191
+
192
+ static set whatsapp(value) {
193
+ if (!this.isPlainObject(value)) {
194
+ throw new Error('WhatsApp must be an object')
195
+ }
196
+
197
+ Object.entries(value).forEach(([key, nestedValue]) => {
198
+ if (!['number', 'restrictToChannel'].includes(key)) {
199
+ throw new Error(`Invalid WhatsApp property: ${key}`)
200
+ }
201
+
202
+ if (nestedValue == null) {
203
+ return
204
+ }
205
+
206
+ if (key === 'number' && typeof nestedValue !== 'string') {
207
+ throw new Error(`Invalid WhatsApp number value: ${nestedValue}`)
208
+ }
209
+
210
+ if (key === 'restrictToChannel' && typeof nestedValue !== 'boolean') {
211
+ throw new Error(`Invalid WhatsApp restrictToChannel value: ${nestedValue}`)
212
+ }
213
+ })
214
+
215
+ this._whatsapp = value
216
+ }
217
+
218
+ static get mode() {
219
+ return this._mode
220
+ }
221
+
222
+ static set mode(value) {
223
+ if (!Object.values(modes).includes(value)) {
224
+ throw new Error(`Invalid mode value: ${value}`)
225
+ }
226
+
227
+ this._mode = value
228
+ }
229
+
163
230
  static get behaviour() {
164
231
  return this._behaviour
165
232
  }
166
233
 
167
234
  static set behaviour(value) {
168
- if (!Object.values(behaviors).includes(value)) {
235
+ if (value == null) {
236
+ this._behaviour = value
237
+ return
238
+ }
239
+
240
+ if (typeof value !== 'object' || Array.isArray(value)) {
169
241
  throw new Error(`Invalid behaviour value: ${value}`)
170
242
  }
171
243
 
172
244
  this._behaviour = value
173
245
  }
174
246
 
247
+ static get hasBehaviourOverride() {
248
+ return this._hasBehaviourOverride
249
+ }
250
+
251
+ static set behaviourOverride(value) {
252
+ this._hasBehaviourOverride = !!value
253
+ }
254
+
175
255
  static get strategy() {
176
256
  if (this._strategy) {
177
257
  return this._strategy
@@ -204,6 +284,10 @@ class Webchat {
204
284
  /^rgba?\(\s*\d{1,3},\s*\d{1,3},\s*\d{1,3},?\s*(0|1|0?\.\d+)?\s*\)$/.test(value)
205
285
  )
206
286
  }
287
+
288
+ static isPlainObject(value) {
289
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
290
+ }
207
291
  }
208
292
 
209
- export { behaviors, Webchat }
293
+ export { modes, Webchat }
package/src/hellotext.js CHANGED
@@ -28,10 +28,16 @@ class Hellotext {
28
28
  this.query = new Query()
29
29
 
30
30
  const businessData = await this.business.hydrate()
31
- const webchatConfig = config.webchat === false ? false : {
32
- ...((businessData && businessData.webchat) || {}),
33
- ...(config.webchat || {}),
34
- }
31
+ const webchatConfig = config.webchat === false ?
32
+ false :
33
+ this.mergeWebchatConfig((businessData && businessData.webchat) || {}, config.webchat || {})
34
+
35
+ const hasExplicitBehaviourOverride = (
36
+ config.webchat &&
37
+ config.webchat !== false &&
38
+ Object.prototype.hasOwnProperty.call(config.webchat, 'behaviour')
39
+ )
40
+ Configuration.webchat.behaviourOverride = hasExplicitBehaviourOverride
35
41
 
36
42
  if (webchatConfig && webchatConfig.id) {
37
43
  Configuration.webchat.assign(webchatConfig)
@@ -43,6 +49,28 @@ class Hellotext {
43
49
  }
44
50
  }
45
51
 
52
+ static mergeWebchatConfig(dashboardConfig, localConfig) {
53
+ return this.deepMergePlainObjects(dashboardConfig, localConfig)
54
+ }
55
+
56
+ static deepMergePlainObjects(base, override) {
57
+ const result = { ...base }
58
+
59
+ Object.entries(override).forEach(([key, value]) => {
60
+ if (this.isPlainObject(value) && this.isPlainObject(result[key])) {
61
+ result[key] = this.deepMergePlainObjects(result[key], value)
62
+ } else {
63
+ result[key] = value
64
+ }
65
+ })
66
+
67
+ return result
68
+ }
69
+
70
+ static isPlainObject(value) {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
72
+ }
73
+
46
74
  /**
47
75
  * Tracks an action that has happened on the page
48
76
  *
@@ -10,6 +10,8 @@ import BusinessesAPI from '../api/businesses'
10
10
  /**
11
11
  * @typedef {Object} BusinessWebchat
12
12
  * @property {String} [id] - Dashboard webchat id configured for the business.
13
+ * @property {Object} [appearance] - Dashboard appearance defaults for the webchat.
14
+ * @property {Object} [whatsapp] - Dashboard WhatsApp handoff defaults for the webchat.
13
15
  */
14
16
 
15
17
  /**