@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,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
+ };