@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.
- package/dist/hellotext.js +1 -1
- package/dist/webchat-emoji-en.js +1 -0
- package/dist/webchat-emoji-es.js +1 -0
- package/dist/webchat-emoji.js +1 -0
- package/index.d.ts +30 -4
- package/lib/api/webchats.cjs +20 -0
- package/lib/api/webchats.js +20 -0
- package/lib/controllers/mixins/usePopover.cjs +7 -2
- package/lib/controllers/mixins/usePopover.js +7 -2
- package/lib/controllers/webchat/emoji_picker_controller.cjs +49 -10
- package/lib/controllers/webchat/emoji_picker_controller.js +66 -8
- package/lib/controllers/webchat/useBehaviour.cjs +74 -0
- package/lib/controllers/webchat/useBehaviour.js +67 -0
- package/lib/controllers/webchat/useOpeningSequence.cjs +103 -0
- package/lib/controllers/webchat/useOpeningSequence.js +96 -0
- package/lib/controllers/webchat/useTeaser.cjs +163 -0
- package/lib/controllers/webchat/useTeaser.js +156 -0
- package/lib/controllers/webchat_controller.cjs +168 -22
- package/lib/controllers/webchat_controller.js +176 -24
- package/lib/core/configuration/webchat.cjs +117 -42
- package/lib/core/configuration/webchat.js +121 -43
- package/lib/hellotext.cjs +28 -4
- package/lib/hellotext.js +28 -1
- package/lib/models/business.cjs +2 -0
- package/lib/models/business.js +2 -0
- package/lib/models/webchat.cjs +25 -0
- package/lib/models/webchat.js +25 -0
- package/package.json +1 -1
- package/src/api/webchats.js +17 -0
- package/src/controllers/mixins/usePopover.js +4 -2
- package/src/controllers/webchat/emoji_picker_controller.js +51 -9
- package/src/controllers/webchat/useBehaviour.js +81 -0
- package/src/controllers/webchat/useOpeningSequence.js +127 -0
- package/src/controllers/webchat/useTeaser.js +192 -0
- package/src/controllers/webchat_controller.js +200 -23
- package/src/core/configuration/webchat.js +127 -43
- package/src/hellotext.js +32 -4
- package/src/models/business.js +2 -0
- 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
|
+
}
|