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