@hellotext/hellotext 2.3.9 → 2.4.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 (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 +168 -22
  19. package/lib/controllers/webchat_controller.js +176 -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 +200 -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,12 @@ export default class extends Controller {
51
56
  'unreadCounter',
52
57
  'typingIndicator',
53
58
  'typingIndicatorTemplate',
59
+ 'teaser',
60
+ 'teaserMessage',
61
+ 'inboundMessageTeaser',
62
+ 'inboundMessageTeaserBody',
63
+ 'openingSequence',
64
+ 'openingSequenceMessage',
54
65
  ]
55
66
 
56
67
  initialize() {
@@ -63,6 +74,7 @@ export default class extends Controller {
63
74
  )
64
75
 
65
76
  this.files = []
77
+ this.messageIds = new Set()
66
78
 
67
79
  this.onMessageReceived = this.onMessageReceived.bind(this)
68
80
  this.onMessageReaction = this.onMessageReaction.bind(this)
@@ -77,13 +89,24 @@ export default class extends Controller {
77
89
  }
78
90
 
79
91
  connect() {
92
+ useBehaviour(this)
80
93
  usePopover(this)
81
-
82
- this.popoverTarget.classList.add(...WebchatConfiguration.classes)
83
- this.triggerTarget.classList.add(...WebchatConfiguration.triggerClasses)
94
+ useOpeningSequence(this)
95
+ useTeaser(this)
84
96
 
85
97
  this.setupFloatingUI({ trigger: this.triggerTarget, popover: this.popoverTarget })
86
98
 
99
+ if (this.hasTeaserTarget) {
100
+ this.setupFloatingUI({
101
+ trigger: this.triggerTarget,
102
+ popover: this.teaserTarget,
103
+ strategy: 'absolute',
104
+ })
105
+ }
106
+
107
+ this.setupTeaser()
108
+ this.setupOpeningSequence()
109
+
87
110
  this.webChatChannel.onMessage(this.onMessageReceived)
88
111
  this.webChatChannel.onTypingStart(this.onTypingStart)
89
112
 
@@ -91,21 +114,22 @@ export default class extends Controller {
91
114
 
92
115
  this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
93
116
 
94
- if (!Hellotext.business.features.white_label) {
95
- this.toolbarTarget.appendChild(LogoBuilder.build())
96
- }
97
-
98
117
  if (this.shouldOpenOnMount) {
99
118
  this.openValue = true
100
119
  }
101
120
 
102
121
  Hellotext.eventEmitter.dispatch('webchat:mounted')
103
122
  this.broadcastChannel.addEventListener('message', this.onOutboundMessageSent)
123
+ this.scheduleBehaviourOpen()
104
124
 
105
125
  super.connect()
106
126
  }
107
127
 
108
128
  disconnect() {
129
+ this.cancelBehaviourOpen()
130
+ this.teardownTeaser()
131
+ this.teardownOpeningSequence()
132
+
109
133
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
110
134
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
111
135
 
@@ -273,7 +297,7 @@ export default class extends Controller {
273
297
  image.src = attachmentUrl
274
298
  image.style.display = 'block'
275
299
 
276
- element.querySelector('[data-attachment-container]').appendChild(image)
300
+ this.messageAttachmentsContainer(element)?.appendChild(image)
277
301
  })
278
302
  }
279
303
 
@@ -291,7 +315,7 @@ export default class extends Controller {
291
315
 
292
316
  onClickOutside(event) {
293
317
  if (
294
- WebchatConfiguration.behaviour === behaviors.POPOVER &&
318
+ WebchatConfiguration.mode === modes.POPOVER &&
295
319
  this.openValue &&
296
320
  event.target.nodeType &&
297
321
  this.element.contains(event.target) === false
@@ -310,6 +334,7 @@ export default class extends Controller {
310
334
 
311
335
  onPopoverOpened() {
312
336
  this.popoverTarget.classList.remove(...this.fadeOutClasses)
337
+ this.dismissTeaserForSession?.()
313
338
 
314
339
  if (!this.onMobile) {
315
340
  this.inputTarget.focus()
@@ -329,6 +354,12 @@ export default class extends Controller {
329
354
  Hellotext.eventEmitter.dispatch('webchat:opened')
330
355
  localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'opened')
331
356
 
357
+ if (this.messageTeaserValue) {
358
+ this.messageTeaserValue = null
359
+ }
360
+
361
+ this.startOpeningSequence()
362
+
332
363
  if (this.unreadCounterTarget.style.display === 'none') return
333
364
 
334
365
  this.unreadCounterTarget.style.display = 'none'
@@ -367,7 +398,11 @@ export default class extends Controller {
367
398
  }
368
399
 
369
400
  onMessageReceived(message) {
370
- const { id, body, attachments } = message
401
+ const { id, body, attachments, teaser } = message
402
+
403
+ if (!this.claimMessageId(id)) return
404
+
405
+ this.hideTeaser?.()
371
406
 
372
407
  if (message.carousel) {
373
408
  return this.insertCarouselMessage(message)
@@ -380,6 +415,8 @@ export default class extends Controller {
380
415
  element.style.display = 'flex'
381
416
 
382
417
  element.querySelector('[data-body]').innerHTML = div.innerHTML
418
+
419
+ element.setAttribute('data-id', id)
383
420
  element.setAttribute('data-hellotext--webchat-target', 'message')
384
421
 
385
422
  if (attachments) {
@@ -388,7 +425,7 @@ export default class extends Controller {
388
425
  image.src = attachmentUrl
389
426
  image.style.display = 'block'
390
427
 
391
- element.querySelector('[data-attachment-container]').appendChild(image)
428
+ this.messageAttachmentsContainer(element)?.appendChild(image)
392
429
  })
393
430
  }
394
431
 
@@ -402,21 +439,55 @@ export default class extends Controller {
402
439
 
403
440
  element.scrollIntoView({ behavior: 'smooth' })
404
441
 
442
+ this.updateMessageTeaser(teaser)
443
+
405
444
  if (this.openValue) {
406
445
  this.messagesAPI.markAsSeen(id)
407
446
  return
408
447
  }
409
448
 
410
- this.unreadCounterTarget.style.display = 'flex'
449
+ this.incrementUnreadCounter()
450
+ }
411
451
 
412
- const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
413
- this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
452
+ // TCP broadcasts may deliver the same incoming message twice before Stimulus
453
+ // has connected the appended message target. Claiming the id in memory closes
454
+ // that window, while the target check still drops messages rendered earlier.
455
+ claimMessageId(id) {
456
+ const messageTargets = this.messageTargets || []
457
+
458
+ if (this.messageIds.has(id)) return false
459
+
460
+ this.messageIds.add(id)
461
+
462
+ return !messageTargets.some(element => element.dataset.id === id)
463
+ }
464
+
465
+ updateMessageTeaser(teaser) {
466
+ this.messageTeaserValue = teaser
467
+
468
+ if (
469
+ !this.messageTeaserValue ||
470
+ !this.hasTeaserTarget ||
471
+ !this.hasInboundMessageTeaserTarget ||
472
+ !this.hasInboundMessageTeaserBodyTarget
473
+ ) {
474
+ return
475
+ }
476
+
477
+ this.teaserMessageTargets.forEach(teaserMessage => teaserMessage.classList.add('hidden'))
478
+ this.inboundMessageTeaserBodyTarget.innerHTML = this.messageTeaserValue
479
+ this.inboundMessageTeaserTarget.classList.remove('hidden')
480
+
481
+ this.teaserTarget.classList.toggle('invisible', this.openValue)
414
482
  }
415
483
 
416
484
  insertCarouselMessage(message) {
417
485
  const html = message.html
418
486
  const element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild
419
487
 
488
+ element.setAttribute('data-id', message.id)
489
+ element.setAttribute('data-hellotext--webchat-target', 'message')
490
+
420
491
  this.clearTypingIndicator()
421
492
  this.messagesContainerTarget.appendChild(element)
422
493
 
@@ -427,14 +498,14 @@ export default class extends Controller {
427
498
  body: element.querySelector('[data-body]')?.innerText || '',
428
499
  })
429
500
 
501
+ this.updateMessageTeaser(message.teaser)
502
+
430
503
  if (this.openValue) {
431
504
  this.messagesAPI.markAsSeen(message.id)
432
505
  return
433
506
  }
434
507
 
435
- this.unreadCounterTarget.style.display = 'flex'
436
- const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
437
- this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
508
+ this.incrementUnreadCounter()
438
509
  }
439
510
 
440
511
  resizeInput() {
@@ -451,6 +522,8 @@ export default class extends Controller {
451
522
  }
452
523
 
453
524
  async sendQuickReplyMessage({ detail: { id, product, buttonId, body, cardElement } }) {
525
+ this.dismissTeaserForSession?.()
526
+
454
527
  const formData = new FormData()
455
528
 
456
529
  formData.append('message[body]', body)
@@ -460,6 +533,7 @@ export default class extends Controller {
460
533
 
461
534
  formData.append('session', Hellotext.session)
462
535
  formData.append('locale', Locale.toString())
536
+ this.appendOpeningSequenceMessageIds(formData)
463
537
 
464
538
  const element = this.buildMessageElement()
465
539
  const attachment = cardElement.querySelector('img')?.cloneNode(true)
@@ -470,7 +544,7 @@ export default class extends Controller {
470
544
  attachment.removeAttribute('width')
471
545
  attachment.removeAttribute('height')
472
546
 
473
- element.querySelector('[data-attachment-container]').appendChild(attachment)
547
+ this.messageAttachmentsContainer(element)?.appendChild(attachment)
474
548
  }
475
549
 
476
550
  if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
@@ -503,6 +577,7 @@ export default class extends Controller {
503
577
  const data = await response.json()
504
578
 
505
579
  this.dispatch('set:id', { target: element, detail: data.id })
580
+ this.clearRevealedOpeningSequenceMessageIds()
506
581
 
507
582
  const message = {
508
583
  id: data.id,
@@ -517,9 +592,94 @@ export default class extends Controller {
517
592
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
518
593
  }
519
594
 
520
- async sendMessage(e) {
595
+ async sendTeaserQuickReply(event) {
596
+ event.preventDefault()
597
+ event.stopPropagation()
598
+
599
+ const button = event.currentTarget
600
+ const value = (button.dataset.value || '').trim()
601
+ const label = [button.dataset.text, button.textContent]
602
+ .map(text => (text || '').trim())
603
+ .find(text => text.length > 0)
604
+ const text = value || label
605
+
606
+ if (!text) return
607
+
608
+ this.dismissTeaserForSession?.()
609
+ this.show()
610
+
611
+ const buttonType = (button.dataset.type || '').trim() || 'quick_reply'
521
612
  const formData = new FormData()
522
613
 
614
+ formData.append('message[body]', text)
615
+ formData.append('session', Hellotext.session)
616
+ formData.append('locale', Locale.toString())
617
+ this.appendOpeningSequenceMessageIds(formData)
618
+
619
+ const element = this.buildMessageElement()
620
+
621
+ element.querySelector('[data-body]').innerText = text
622
+
623
+ if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
624
+ this.messagesContainerTarget.insertBefore(element, this.typingIndicatorTarget)
625
+ } else {
626
+ this.messagesContainerTarget.appendChild(element)
627
+ }
628
+
629
+ element.scrollIntoView({ behavior: 'smooth' })
630
+
631
+ this.broadcastChannel.postMessage({
632
+ type: 'message:sent',
633
+ element: element.outerHTML,
634
+ })
635
+
636
+ if (!this.typingIndicatorVisible) {
637
+ clearTimeout(this.optimisticTypingTimeout)
638
+ this.optimisticTypingTimeout = setTimeout(() => {
639
+ this.showOptimisticTypingIndicator()
640
+ }, this.optimisticTypingIndicatorWaitValue)
641
+ }
642
+
643
+ const response = await this.messagesAPI.create(formData)
644
+
645
+ if (response.failed) {
646
+ clearTimeout(this.optimisticTypingTimeout)
647
+
648
+ this.broadcastChannel.postMessage({
649
+ type: 'message:failed',
650
+ id: element.id,
651
+ })
652
+
653
+ return element.classList.add('failed')
654
+ }
655
+
656
+ const data = await response.json()
657
+ element.setAttribute('data-id', data.id)
658
+ this.clearRevealedOpeningSequenceMessageIds()
659
+
660
+ Hellotext.eventEmitter.dispatch('webchat:message:sent', {
661
+ id: data.id,
662
+ body: text,
663
+ attachments: [],
664
+ type: 'quick_reply',
665
+ teaser: {
666
+ text: label || text,
667
+ value: value || text,
668
+ type: buttonType,
669
+ },
670
+ })
671
+
672
+ if (data.conversation && data.conversation !== this.conversationIdValue) {
673
+ this.conversationIdValue = data.conversation
674
+ this.webChatChannel.updateSubscriptionWith(this.conversationIdValue)
675
+ }
676
+
677
+ if (this.typingIndicatorVisible) {
678
+ this.resetTypingIndicatorTimer()
679
+ }
680
+ }
681
+
682
+ async sendMessage(e) {
523
683
  const message = {
524
684
  body: this.inputTarget.value,
525
685
  attachments: this.files,
@@ -533,6 +693,10 @@ export default class extends Controller {
533
693
  return
534
694
  }
535
695
 
696
+ this.dismissTeaserForSession?.()
697
+
698
+ const formData = new FormData()
699
+
536
700
  if (this.inputTarget.value.trim().length > 0) {
537
701
  formData.append('message[body]', this.inputTarget.value)
538
702
  } else {
@@ -545,6 +709,7 @@ export default class extends Controller {
545
709
 
546
710
  formData.append('session', Hellotext.session)
547
711
  formData.append('locale', Locale.toString())
712
+ this.appendOpeningSequenceMessageIds(formData)
548
713
 
549
714
  const element = this.buildMessageElement()
550
715
 
@@ -558,7 +723,7 @@ export default class extends Controller {
558
723
 
559
724
  if (attachments.length > 0) {
560
725
  attachments.forEach(attachment => {
561
- element.querySelector('[data-attachment-container]').appendChild(attachment.cloneNode(true))
726
+ this.messageAttachmentsContainer(element)?.appendChild(attachment.cloneNode(true))
562
727
  })
563
728
  }
564
729
 
@@ -614,6 +779,7 @@ export default class extends Controller {
614
779
  const data = await response.json()
615
780
  element.setAttribute('data-id', data.id)
616
781
  message.id = data.id
782
+ this.clearRevealedOpeningSequenceMessageIds()
617
783
 
618
784
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
619
785
 
@@ -644,6 +810,17 @@ export default class extends Controller {
644
810
  return element
645
811
  }
646
812
 
813
+ messageAttachmentsContainer(element) {
814
+ return element.querySelector('[data-attachments-container], [data-attachment-container]')
815
+ }
816
+
817
+ incrementUnreadCounter() {
818
+ this.unreadCounterTarget.style.display = 'flex'
819
+
820
+ const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
821
+ this.unreadCounterTarget.innerText = Math.min(unreadCount, 9)
822
+ }
823
+
647
824
  openAttachment() {
648
825
  this.attachmentInputTarget.click()
649
826
  }
@@ -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
  *