UNPKG

@hellotext/hellotext

Version:
196 lines (158 loc) 7.13 kB
// The teaser markup is already present in the document; this mixin only manages // the runtime around it. Keeping the teaser policy here gives the controller one // place to delegate click-to-open, the pre-conversation presentation, and timer // cleanup. Message sending stays on the controller because it owns the API flow // and optimistic customer bubble insertion. export const useTeaser = controller => { Object.assign(controller, { // Called from `connect` after `usePopover` has given the controller `show`. // The teaser is optional, so setup first prepares reusable lifecycle state, // wires the click surface when present, and then lets eligibility decide // whether this session may still show the presentation. setupTeaser() { this.teaserCycleTimeout = null this.teaserMessages = [] this.boundOnTeaserClick = this.boundOnTeaserClick || this.onTeaserClick.bind(this) if (!this.hasTeaserTarget) return this.teaserTarget.addEventListener('click', this.boundOnTeaserClick) this.startTeaserPresentation() }, // This is the shared teardown path for Stimulus disconnect. Cycling timers // and DOM listeners both outlive the current call stack, so they need to be // cancelled explicitly when the rendered widget leaves the page. teardownTeaser() { this.stopTeaserCycle() if (this.hasTeaserTarget && this.boundOnTeaserClick) { this.teaserTarget.removeEventListener('click', this.boundOnTeaserClick) } }, // The current DOM is the source of truth. Each setup pass collects the // rendered teaser messages from the teaser surface itself, which keeps the // target list small and avoids carrying stale nodes between presentations. collectTeaserMessages() { if (!this.hasTeaserTarget) return [] return Array.from(this.teaserTarget.querySelectorAll('[data-teaser-message]')) }, // The teaser is a one-time, pre-conversation presentation. It can run while // the popover is closed, but any active conversation signal makes the teaser // ineligible for the rest of this browser session. startTeaserPresentation() { this.stopTeaserCycle() this.teaserMessages = this.collectTeaserMessages() if (!this.hasTeaserTarget) return if (this.teaserMessages.length === 0) { this.hideTeaser() return } if (this.openValue) { this.dismissTeaserForSession() return } if (this.conversationIdValue || this.hasRenderedConversationMessages()) { this.dismissTeaserForSession() return } if (this.teaserSeenForSession()) { this.hideTeaser() return } this.teaserTarget.classList.remove('invisible') this.showTeaserMessage(0) if (this.teaserMessages.length < 2) return this.scheduleNextTeaserMessage(0) }, // Delays belong to the currently visible message, so each teaser controls // how long it remains on screen before the next one replaces it. The // presentation stops after the last message instead of looping forever. scheduleNextTeaserMessage(currentIndex) { const nextIndex = currentIndex + 1 if (nextIndex >= this.teaserMessages.length) return const currentMessage = this.teaserMessages[currentIndex] const delay = this.teaserPresentationDelay(currentMessage) this.teaserCycleTimeout = window.setTimeout(() => { this.teaserCycleTimeout = null this.showTeaserMessage(nextIndex) this.scheduleNextTeaserMessage(nextIndex) }, delay) }, // Visibility is managed with the existing `hidden` class for initially // concealed teaser messages. This keeps JS from restructuring teaser markup. showTeaserMessage(index) { this.teaserMessages.forEach((message, messageIndex) => { message.classList.toggle('hidden', messageIndex !== index) }) }, // Safe to call even when no timer exists. Lifecycle hooks call this before // starting, hiding, or tearing down the teaser so only one cycle can be alive. stopTeaserCycle() { if (this.teaserCycleTimeout === null || this.teaserCycleTimeout === undefined) return window.clearTimeout(this.teaserCycleTimeout) this.teaserCycleTimeout = null }, // Invalid or missing delay values should not break teaser rendering. Treat // them as zero, then use a small minimum so zero-delay presentations advance // deliberately instead of flashing through every message in one frame. teaserMessageDelay(message) { const delay = Number(message.dataset.delaySeconds || 0) return Number.isFinite(delay) ? delay : 0 }, teaserPresentationDelay(message) { return Math.max(this.teaserMessageDelay(message) * 1000, 250) }, // A rendered transcript means the visitor is no longer in the pre-conversation // state. The hidden template is deliberately excluded because it is not a live // customer-visible message. hasRenderedConversationMessages() { let messages = [] try { messages = Array.from(this.messageTargets || []) } catch (_error) { messages = [] } return messages.some(message => message !== this.messageTemplateTarget) }, teaserSeenKey() { const teaserVersion = this.hasTeaserTarget ? this.teaserTarget.dataset.teaserVersion : '' const versionSegment = teaserVersion ? `:${teaserVersion}` : '' return `hellotext:webchat:${this.idValue || this.element.id}:teaser-seen${versionSegment}` }, teaserSeenForSession() { try { return window.sessionStorage.getItem(this.teaserSeenKey()) === 'true' } catch (_error) { return false } }, markTeaserSeenForSession() { try { window.sessionStorage.setItem(this.teaserSeenKey(), 'true') } catch (_error) { // Storage can be unavailable in locked-down browsers. The in-memory hide // still keeps the active controller from showing the teaser again. } }, // Opening or sending ends the pre-conversation window for this browser // session. Incoming message teasers are separate ephemeral content, so they // use `hideTeaser`/`updateMessageTeaser` without writing this session flag. dismissTeaserForSession() { this.markTeaserSeenForSession() this.hideTeaser() }, // Stopping the timer before hiding prevents queued presentation steps from // flipping hidden messages after the teaser surface has been dismissed. hideTeaser() { this.stopTeaserCycle() if (this.hasTeaserTarget) { this.teaserTarget.classList.add('invisible') } }, // A teaser surface click is a user request to open chat, but links inside the // teaser already have native browser behavior. Leaving anchors alone keeps // external URLs and `tel:` actions working without extra JS. onTeaserClick(event) { if (event.target.closest('a')) return this.dismissTeaserForSession() this.show() }, }) }