diff --git a/packages/journey-manager/src/index.ts b/packages/journey-manager/src/index.ts index 6b3f5250..5eb60810 100644 --- a/packages/journey-manager/src/index.ts +++ b/packages/journey-manager/src/index.ts @@ -355,7 +355,10 @@ export const run = async (props: RunProps): Promise => { await waitUntilDomContentLoaded(); - const triggeredSteps = getTriggeredSteps(props.config.conversationId); + let triggeredSteps = getTriggeredSteps(props.config.conversationId); + + // TODO: type this more accurately + let uiElement: any; /** * Digression detection @@ -379,10 +382,6 @@ export const run = async (props: RunProps): Promise => { }, ); - if (isDigressionDetectable && !urlConditions.some(matchesUrlCondition)) { - props.onDigression?.(client); - } - const sendStep = (stepId: string, once: boolean): void => { if ( triggeredSteps.some((triggeredStep) => triggeredStep.stepId === stepId) @@ -391,7 +390,13 @@ export const run = async (props: RunProps): Promise => { return; } } else { - triggeredSteps.push({ stepId, url: window.location.toString() }); + triggeredSteps = [ + ...triggeredSteps, + { stepId, url: window.location.toString() }, + ]; + if (uiElement != null) { + uiElement.triggeredSteps = triggeredSteps; + } saveTriggeredSteps(props.config.conversationId, triggeredSteps); } props.onStep?.(stepId); @@ -524,9 +529,6 @@ export const run = async (props: RunProps): Promise => { * UI management */ - // TODO: type this more accurately - let uiElement: any; - const setHighlights = debounce((): void => { const highlightElements = findActiveTriggers("click").flatMap( (activeTrigger) => activeTrigger.elements, @@ -543,41 +545,10 @@ export const run = async (props: RunProps): Promise => { uiElement.style.zIndex = 1000; uiElement.config = props.ui; uiElement.client = client; - const handleAction = (ev: any): void => { - const action = ev.detail?.action; - if (action == null) { - return; - } - if (action === "escalate" && props.ui?.onEscalation != null) { - props.ui.onEscalation({ sendStep: client.sendStep }); - return; - } - if (action === "end" && props.ui?.onEnd != null) { - props.ui.onEnd({ sendStep: client.sendStep }); - return; - } - if (action === "previous") { - if (props.ui?.onPreviousStep != null) { - props.ui.onPreviousStep({ - sendStep: client.sendStep, - triggeredSteps, - }); - } else { - const lastTriggeredStep = triggeredSteps[triggeredSteps.length - 1]; - if (lastTriggeredStep != null) { - sendStep(lastTriggeredStep.stepId, false); - // Redirect to previous page if the last triggered step occurred on it - if (lastTriggeredStep.url !== window.location.toString()) { - window.location.href = lastTriggeredStep.url; - } - } - } - } - }; + uiElement.triggeredSteps = triggeredSteps; if (props.ui.highlights ?? false) { setHighlights(); } - uiElement.addEventListener("action", handleAction); document.body.appendChild(uiElement); teardownUiElement = () => { document.body.removeChild(uiElement); @@ -621,7 +592,23 @@ export const run = async (props: RunProps): Promise => { * Change detection */ + let digressionCallbackCalled = false; + const documentObserver = new MutationObserver((mutations) => { + if (isDigressionDetectable) { + const userHasDigressed = !urlConditions.some(matchesUrlCondition); + if (userHasDigressed) { + // Avoid calling the digression callback multiple times after a digression has been detected + if (!digressionCallbackCalled) { + props.onDigression?.(client); + } + digressionCallbackCalled = true; + uiElement.digression = true; + } else { + digressionCallbackCalled = false; + uiElement.digression = false; + } + } // If any of the added nodes are inside matches on appear events, trigger those events const targets = withElementsSync(appearSteps); mutations.forEach((mutation) => { diff --git a/packages/journey-manager/src/ui.tsx b/packages/journey-manager/src/ui.tsx index 6a121d4c..bb82dd8b 100644 --- a/packages/journey-manager/src/ui.tsx +++ b/packages/journey-manager/src/ui.tsx @@ -38,6 +38,33 @@ export interface Theme { fontFamily: string; } +interface HandlerArg { + sendStep: Client["sendStep"]; + triggeredSteps: Array<{ stepId: string; url: string }>; +} + +/** + * Button configuration + */ +export interface ButtonConfig { + /** + * Button label + */ + label: string; + /** + * Button confirmation: if present, the button click handler only triggers after the confirmation button is hit + */ + confirmation?: string; + /** + * Icon URL + */ + iconUrl?: string; + /** + * Click handler + */ + onClick: (config: HandlerArg) => void; +} + /** * Deep partial variant of the UI theme, input by the library user */ @@ -68,6 +95,10 @@ export interface UiConfig { * Render highlights */ highlights?: boolean; + /** + * URL for the button icon + */ + iconUrl?: string; /** * UI theme */ @@ -76,17 +107,51 @@ export interface UiConfig { * Escalation handler */ onEscalation?: (config: { sendStep: Client["sendStep"] }) => void; + /** + * Escalation button label + */ + escalationButtonLabel?: string; + /** + * Escalation confirmation + */ + escalationConfirmation?: string; /** * End handler */ onEnd?: (config: { sendStep: Client["sendStep"] }) => void; + /** + * End button label + */ + endButtonLabel?: string; + /** + * End confirmation + */ + endConfirmation?: string; /** * On previous step */ - onPreviousStep?: (config: { - sendStep: Client["sendStep"]; - triggeredSteps: Array<{ stepId: string; url: string }>; - }) => void; + onPreviousStep?: (config: HandlerArg) => void; + /** + * Previous step button label + */ + previousStepButtonLabel?: string; + /** + * Custom buttons + */ + buttons?: ButtonConfig[]; + /** + * If this is set, the journey manager will show a call-to-action tooltip to invite the user to interact with the overlay pin. + * it will be shown only if the user never interacts with the overlay pin, after `tooltipShowAfterMs` milliseconds. + */ + nudgeContent?: string; + /** + * Show nudge tooltip after this many milliseconds + */ + nudgeShowAfterMs?: number; + /** + * Hide nudge tooltip after it's been shown for this many milliseconds + */ + nudgeHideAfterMs?: number; } const defaultTheme: Theme = { @@ -172,6 +237,82 @@ button { background-color: var(--primary-hover); } +.pin-bubble-content { + line-height: 1.4; + padding: 4px 6px; +} + +.pin-bubble-container { + position: fixed; + bottom: 66px; + right: 8px; + border-radius: 6px; + box-sizing: border-box; + width: fit-content; + max-width: calc(100% - 38px); + box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + z-index: 1001; + padding: 4px; + background-color: ${tinycolor(theme.colors.primary).darken(10).toRgbString()}; + color: #fff; + transition: opacity 0.2s, transform 0.2s; +} + +.pin-bubble-container::after { + position: absolute; + top: 100%; + right: 22px; + content: " "; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid ${tinycolor(theme.colors.primary).darken(10).toRgbString()}; +} + +.pin-bubble-container.active { + transform: translate3d(0, 0, 0); + pointer-events: all; +} + +.pin-bubble-container.inactive { + opacity: 0; + transform: translate3d(0, 10px, 0); + pointer-events: none; +} + +.pin-bubble-button { + height: 32px; + flex: 0 0 32px; + border: 0; + color: white; + cursor: pointer; + padding: 6px; + display: flex; + align - items: center; + justify - content: center; + border - radius: 6px; + background: none; +} +.pin-bubble-button svg { + width: 100 %; + height: 100 %; + fill: white; +} +.pin-bubble-button:hover { + background - color: rgba(255, 255, 255, 0.1); +} +.pin-bubble-button:focus { + outline: none; + box - shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.2); +} + + + .drawer { position: fixed; top: 0; @@ -443,8 +584,6 @@ const Highlight: FunctionComponent<{ element: HTMLElement }> = ({ ); }; -type Action = "end" | "escalate" | "previous"; - type ControlCenterStatus = | null | "pending-escalation" @@ -454,13 +593,44 @@ type ControlCenterStatus = const ControlCenter: FunctionComponent<{ config: UiConfig; + client: Client; + triggeredSteps: TriggeredStep[]; digression: boolean; highlightElements: HTMLElement[]; - onAction: (action: Action) => void; -}> = ({ config, highlightElements, digression, onAction }) => { +}> = ({ config, client, triggeredSteps, highlightElements, digression }) => { + const [hasBeenOpened, setHasBeenOpened] = useState(false); const [isOpen, setIsOpen] = useState(false); const [status, setStatus] = useState(null); const drawerContentRef = useRef(null); + const [isNudgeVisible, setIsNudgeVisible] = useState(false); + + useEffect(() => { + if (isOpen) setHasBeenOpened(true); + }, [isOpen]); + + useEffect(() => { + if (hasBeenOpened || config.nudgeContent == null) { + setIsNudgeVisible(false); + return; + } + + let hideTimeout: null | NodeJS.Timeout = null; + const showTimeout = setTimeout(() => { + setIsNudgeVisible(true); + hideTimeout = setTimeout(() => { + setIsNudgeVisible(false); + }, config.nudgeHideAfterMs ?? 20_000); + }, config.nudgeShowAfterMs ?? 3_000); + return () => { + clearTimeout(showTimeout); + if (hideTimeout) clearTimeout(hideTimeout); + }; + }, [ + config.nudgeContent, + config.nudgeShowAfterMs, + config.nudgeHideAfterMs, + hasBeenOpened, + ]); const successMessage = useMemo(() => { if (status === "success-escalation") { @@ -472,9 +642,37 @@ const ControlCenter: FunctionComponent<{ return null; }, [status]); + const onPreviousStep = config.onPreviousStep + ? () => { + config.onPreviousStep?.({ + sendStep: client.sendStep, + triggeredSteps, + }); + } + : () => { + const lastTriggeredStep = triggeredSteps[triggeredSteps.length - 1]; + if (lastTriggeredStep != null) { + client.sendStep(lastTriggeredStep.stepId).catch((err) => { + // eslint-disable-next-line no-console + console.warn(err); + }); + // Redirect to previous page if the last triggered step occurred on it + if (lastTriggeredStep.url !== window.location.toString()) { + window.location.href = lastTriggeredStep.url; + } + } + }; + return ( <> + { + setIsNudgeVisible(false); + }} + content={config.nudgeContent ?? ""} + />
{ if ( event.target != null && @@ -516,19 +714,19 @@ const ControlCenter: FunctionComponent<{
{config.onEscalation != null ? ( ) : null} @@ -547,7 +745,7 @@ const ControlCenter: FunctionComponent<{ ) : null} + {(config.buttons ?? []).map((buttonConfig, buttonIndex) => ( + + ))}
)}
@@ -584,21 +795,30 @@ const ControlCenter: FunctionComponent<{ ); }; +interface TriggeredStep { + stepId: string; + url: string; +} + /** * @hidden @internal */ export class JourneyManagerElement extends HTMLElement { _shadowRoot: ShadowRoot | null = null; + _client: Client | null = null; + _triggeredSteps: TriggeredStep[] | null = null; _config: UiConfig | null = null; - _digression?: boolean; + _digression: boolean = false; _highlightElements: HTMLElement[] = []; /** * Set digression attribute */ set digression(value: boolean) { - this._digression = value; - this.render(); + if (this._digression !== value) { + this._digression = value; + this.render(); + } } /** @@ -609,6 +829,22 @@ export class JourneyManagerElement extends HTMLElement { this.render(); } + /** + * Set SDK client + */ + set client(value: Client) { + this._client = value; + this.render(); + } + + /** + * Set triggered steps + */ + set triggeredSteps(value: TriggeredStep[]) { + this._triggeredSteps = value; + this.render(); + } + /** * Set UI configuration */ @@ -622,23 +858,20 @@ export class JourneyManagerElement extends HTMLElement { */ render(): void { this._shadowRoot = this._shadowRoot ?? this.attachShadow({ mode: "open" }); - if (this._config == null) { + if ( + this._config == null || + this._client == null || + this._triggeredSteps == null + ) { return; } render( { - this.dispatchEvent( - new CustomEvent("action", { - detail: { - action, - }, - }), - ); - }} />, this._shadowRoot, ); @@ -653,3 +886,27 @@ export class JourneyManagerElement extends HTMLElement { } } } + +// PinBubble + +interface PinBubbleProps { + isActive: boolean; + content: string; + onClick: () => void; +} +// eslint-disable-next-line jsdoc/require-returns, jsdoc/require-param +/** @hidden @internal */ +export const PinBubble: FunctionComponent = ({ + isActive, + content, + onClick, +}) => ( +
+
{content}
+ +
+); diff --git a/packages/website/src/components/Prototyping.tsx b/packages/website/src/components/Prototyping.tsx index 478e40b5..348cc7dc 100644 --- a/packages/website/src/components/Prototyping.tsx +++ b/packages/website/src/components/Prototyping.tsx @@ -118,12 +118,23 @@ const runJourneyManager = async (): Promise => { highlights: true, theme: { fontFamily: "'Neue Haas Grotesk'", - colors: { highlight: "#42f5d4" }, + colors: { highlight: "#7dd3fc" }, }, - onEscalation: () => { + escalationButtonLabel: "Hand Off To Specialist", + onEscalation: (args) => { // eslint-disable-next-line no-console - console.log("escalation"); + console.log("escalation", args); }, + nudgeContent: "click me! click me!", + buttons: [ + { + label: "Google Chat", + onClick: () => { + // eslint-disable-next-line no-console + console.log("Google Chat button click"); + }, + }, + ], }, triggers: triggersForRun(), onStep: (stepId) => {