@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
@@ -0,0 +1,127 @@
1
+ // Opening sequence markup is already present in the document; this mixin only
2
+ // manages when those staged messages become visible and which ids should travel
3
+ // with the first customer message. It never creates a conversation or posts the
4
+ // staged messages by itself.
5
+ export const useOpeningSequence = controller => {
6
+ Object.assign(controller, {
7
+ // Called from `connect` to prepare lifecycle state before the webchat opens.
8
+ // The sequence may not exist on every widget, so the rest of the runtime can
9
+ // safely call these helpers even when there are no sequence targets.
10
+ setupOpeningSequence() {
11
+ this.openingSequenceStarted = false
12
+ this.openingSequenceCancelled = false
13
+ this.openingSequenceTimeout = null
14
+ this.openingSequenceMessages = []
15
+ this.revealedOpeningSequenceMessageIds = []
16
+ },
17
+
18
+ // Disconnect shares the same cancellation path as a user send. Both cases
19
+ // must stop pending timers so hidden staged messages cannot reveal later.
20
+ teardownOpeningSequence() {
21
+ this.cancelOpeningSequence()
22
+ },
23
+
24
+ // The sequence is a first-conversation affordance only. A present
25
+ // conversation id means the transcript already exists, so staged messages
26
+ // should remain untouched.
27
+ startOpeningSequence() {
28
+ this.openingSequenceMessages = Array.from(this.openingSequenceMessageTargets || [])
29
+
30
+ if (!this.openingSequenceCanStart()) return
31
+
32
+ this.openingSequenceStarted = true
33
+ this.openingSequenceCancelled = false
34
+ this.revealedOpeningSequenceMessageIds = []
35
+ this.playOpeningSequenceMessageAt(0)
36
+ },
37
+
38
+ openingSequenceCanStart() {
39
+ return (
40
+ !this.conversationIdValue &&
41
+ this.hasOpeningSequenceTarget &&
42
+ this.openingSequenceMessages.length > 0 &&
43
+ !this.openingSequenceStarted
44
+ )
45
+ },
46
+
47
+ // Each staged message owns the delay before it appears. A zero-second delay
48
+ // still uses a timeout so reveal work stays outside the popover-open call
49
+ // stack and cancellation has one consistent path.
50
+ playOpeningSequenceMessageAt(index) {
51
+ const message = this.openingSequenceMessages[index]
52
+
53
+ if (!message) return
54
+
55
+ const delay = this.openingSequenceMessageDelay(message) * 1000
56
+
57
+ this.openingSequenceTimeout = window.setTimeout(() => {
58
+ this.openingSequenceTimeout = null
59
+
60
+ if (this.openingSequenceCancelled) return
61
+
62
+ this.revealOpeningSequenceMessage(message)
63
+ this.playOpeningSequenceMessageAt(index + 1)
64
+ }, delay)
65
+ },
66
+
67
+ // Revealing moves the staged node into the live message list. The message
68
+ // markup itself is preserved; JS only relocates it, unhides it, and remembers
69
+ // the hashed id if the visitor actually saw it.
70
+ revealOpeningSequenceMessage(message) {
71
+ this.messagesContainerTarget.insertBefore(message, this.messageTemplateTarget)
72
+ message.hidden = false
73
+ this.recordOpeningSequenceMessage(message)
74
+ this.scrollOpeningSequenceToBottom()
75
+ },
76
+
77
+ recordOpeningSequenceMessage(message) {
78
+ const id = message.dataset.openingSequenceMessageId
79
+
80
+ if (!id || this.revealedOpeningSequenceMessageIds.includes(id)) return
81
+
82
+ this.revealedOpeningSequenceMessageIds.push(id)
83
+ },
84
+
85
+ openingSequenceMessageDelay(message) {
86
+ const delay = Number(message.dataset.delaySeconds || 0)
87
+
88
+ return Number.isFinite(delay) ? delay : 0
89
+ },
90
+
91
+ scrollOpeningSequenceToBottom() {
92
+ if (!this.messagesContainerTarget.scroll) return
93
+
94
+ this.messagesContainerTarget.scroll({
95
+ top: this.messagesContainerTarget.scrollHeight,
96
+ behavior: 'smooth',
97
+ })
98
+ },
99
+
100
+ cancelOpeningSequence() {
101
+ this.openingSequenceCancelled = true
102
+
103
+ if (this.openingSequenceTimeout === null || this.openingSequenceTimeout === undefined) return
104
+
105
+ window.clearTimeout(this.openingSequenceTimeout)
106
+ this.openingSequenceTimeout = null
107
+ },
108
+
109
+ // Customer sends promote only what was actually revealed. Calling this also
110
+ // interrupts pending reveals so the payload cannot include later messages.
111
+ appendOpeningSequenceMessageIds(formData) {
112
+ this.cancelOpeningSequence()
113
+
114
+ const ids = this.revealedOpeningSequenceMessageIds || []
115
+
116
+ ids.forEach(id => {
117
+ formData.append('message[opening_sequence_message_ids][]', id)
118
+ })
119
+ },
120
+
121
+ // Clear after a successful customer send. Failed sends keep the ids available
122
+ // so retrying the first message can still promote the revealed sequence.
123
+ clearRevealedOpeningSequenceMessageIds() {
124
+ this.revealedOpeningSequenceMessageIds = []
125
+ },
126
+ })
127
+ }
@@ -0,0 +1,192 @@
1
+ // The teaser markup is already present in the document; this mixin only manages
2
+ // the runtime around it. Keeping the teaser policy here gives the controller one
3
+ // place to delegate click-to-open, the pre-conversation presentation, and timer
4
+ // cleanup. Message sending stays on the controller because it owns the API flow
5
+ // and optimistic customer bubble insertion.
6
+ export const useTeaser = controller => {
7
+ Object.assign(controller, {
8
+ // Called from `connect` after `usePopover` has given the controller `show`.
9
+ // The teaser is optional, so setup first prepares reusable lifecycle state,
10
+ // wires the click surface when present, and then lets eligibility decide
11
+ // whether this session may still show the presentation.
12
+ setupTeaser() {
13
+ this.teaserCycleTimeout = null
14
+ this.teaserMessages = []
15
+ this.boundOnTeaserClick = this.boundOnTeaserClick || this.onTeaserClick.bind(this)
16
+
17
+ if (!this.hasTeaserTarget) return
18
+
19
+ this.teaserTarget.addEventListener('click', this.boundOnTeaserClick)
20
+ this.startTeaserPresentation()
21
+ },
22
+
23
+ // This is the shared teardown path for Stimulus disconnect. Cycling timers
24
+ // and DOM listeners both outlive the current call stack, so they need to be
25
+ // cancelled explicitly when the rendered widget leaves the page.
26
+ teardownTeaser() {
27
+ this.stopTeaserCycle()
28
+
29
+ if (this.hasTeaserTarget && this.boundOnTeaserClick) {
30
+ this.teaserTarget.removeEventListener('click', this.boundOnTeaserClick)
31
+ }
32
+ },
33
+
34
+ // The current DOM is the source of truth. Each setup pass collects the
35
+ // rendered teaser messages from the teaser surface itself, which keeps the
36
+ // target list small and avoids carrying stale nodes between presentations.
37
+ collectTeaserMessages() {
38
+ if (!this.hasTeaserTarget) return []
39
+
40
+ return Array.from(this.teaserTarget.querySelectorAll('[data-teaser-message]'))
41
+ },
42
+
43
+ // The teaser is a one-time, pre-conversation presentation. It can run while
44
+ // the popover is closed, but any active conversation signal makes the teaser
45
+ // ineligible for the rest of this browser session.
46
+ startTeaserPresentation() {
47
+ this.stopTeaserCycle()
48
+ this.teaserMessages = this.collectTeaserMessages()
49
+
50
+ if (!this.hasTeaserTarget) return
51
+
52
+ if (this.teaserMessages.length === 0) {
53
+ this.hideTeaser()
54
+ return
55
+ }
56
+
57
+ if (this.openValue) {
58
+ this.dismissTeaserForSession()
59
+ return
60
+ }
61
+
62
+ if (this.conversationIdValue || this.hasRenderedConversationMessages()) {
63
+ this.dismissTeaserForSession()
64
+ return
65
+ }
66
+
67
+ if (this.teaserSeenForSession()) {
68
+ this.hideTeaser()
69
+ return
70
+ }
71
+
72
+ this.teaserTarget.classList.remove('invisible')
73
+ this.showTeaserMessage(0)
74
+
75
+ if (this.teaserMessages.length < 2) return
76
+
77
+ this.scheduleNextTeaserMessage(0)
78
+ },
79
+
80
+ // Delays belong to the currently visible message, so each teaser controls
81
+ // how long it remains on screen before the next one replaces it. The
82
+ // presentation stops after the last message instead of looping forever.
83
+ scheduleNextTeaserMessage(currentIndex) {
84
+ const nextIndex = currentIndex + 1
85
+
86
+ if (nextIndex >= this.teaserMessages.length) return
87
+
88
+ const currentMessage = this.teaserMessages[currentIndex]
89
+ const delay = this.teaserPresentationDelay(currentMessage)
90
+
91
+ this.teaserCycleTimeout = window.setTimeout(() => {
92
+ this.teaserCycleTimeout = null
93
+ this.showTeaserMessage(nextIndex)
94
+ this.scheduleNextTeaserMessage(nextIndex)
95
+ }, delay)
96
+ },
97
+
98
+ // Visibility is managed with the existing `hidden` class for initially
99
+ // concealed teaser messages. This keeps JS from restructuring teaser markup.
100
+ showTeaserMessage(index) {
101
+ this.teaserMessages.forEach((message, messageIndex) => {
102
+ message.classList.toggle('hidden', messageIndex !== index)
103
+ })
104
+ },
105
+
106
+ // Safe to call even when no timer exists. Lifecycle hooks call this before
107
+ // starting, hiding, or tearing down the teaser so only one cycle can be alive.
108
+ stopTeaserCycle() {
109
+ if (this.teaserCycleTimeout === null || this.teaserCycleTimeout === undefined) return
110
+
111
+ window.clearTimeout(this.teaserCycleTimeout)
112
+ this.teaserCycleTimeout = null
113
+ },
114
+
115
+ // Invalid or missing delay values should not break teaser rendering. Treat
116
+ // them as zero, then use a small minimum so zero-delay presentations advance
117
+ // deliberately instead of flashing through every message in one frame.
118
+ teaserMessageDelay(message) {
119
+ const delay = Number(message.dataset.delaySeconds || 0)
120
+
121
+ return Number.isFinite(delay) ? delay : 0
122
+ },
123
+
124
+ teaserPresentationDelay(message) {
125
+ return Math.max(this.teaserMessageDelay(message) * 1000, 250)
126
+ },
127
+
128
+ // A rendered transcript means the visitor is no longer in the pre-conversation
129
+ // state. The hidden template is deliberately excluded because it is not a live
130
+ // customer-visible message.
131
+ hasRenderedConversationMessages() {
132
+ let messages = []
133
+
134
+ try {
135
+ messages = Array.from(this.messageTargets || [])
136
+ } catch (_error) {
137
+ messages = []
138
+ }
139
+
140
+ return messages.some(message => message !== this.messageTemplateTarget)
141
+ },
142
+
143
+ teaserSeenKey() {
144
+ return `hellotext:webchat:${this.idValue || this.element.id}:teaser-seen`
145
+ },
146
+
147
+ teaserSeenForSession() {
148
+ try {
149
+ return window.sessionStorage.getItem(this.teaserSeenKey()) === 'true'
150
+ } catch (_error) {
151
+ return false
152
+ }
153
+ },
154
+
155
+ markTeaserSeenForSession() {
156
+ try {
157
+ window.sessionStorage.setItem(this.teaserSeenKey(), 'true')
158
+ } catch (_error) {
159
+ // Storage can be unavailable in locked-down browsers. The in-memory hide
160
+ // still keeps the active controller from showing the teaser again.
161
+ }
162
+ },
163
+
164
+ // Opening or sending ends the pre-conversation window for this browser
165
+ // session. Incoming message teasers are separate ephemeral content, so they
166
+ // use `hideTeaser`/`updateMessageTeaser` without writing this session flag.
167
+ dismissTeaserForSession() {
168
+ this.markTeaserSeenForSession()
169
+ this.hideTeaser()
170
+ },
171
+
172
+ // Stopping the timer before hiding prevents queued presentation steps from
173
+ // flipping hidden messages after the teaser surface has been dismissed.
174
+ hideTeaser() {
175
+ this.stopTeaserCycle()
176
+
177
+ if (this.hasTeaserTarget) {
178
+ this.teaserTarget.classList.add('invisible')
179
+ }
180
+ },
181
+
182
+ // A teaser surface click is a user request to open chat, but links inside the
183
+ // teaser already have native browser behavior. Leaving anchors alone keeps
184
+ // external URLs and `tel:` actions working without extra JS.
185
+ onTeaserClick(event) {
186
+ if (event.target.closest('a')) return
187
+
188
+ this.dismissTeaserForSession()
189
+ this.show()
190
+ },
191
+ })
192
+ }