From e7e906c001362096a281a72c3ac4bc89227f345b Mon Sep 17 00:00:00 2001 From: Peter Szerzo Date: Tue, 23 Jul 2024 12:09:59 +0200 Subject: [PATCH 1/4] Configuration for new features --- packages/journey-manager/src/ui.tsx | 44 +++++++++++++++++++ .../website/src/components/Prototyping.tsx | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/journey-manager/src/ui.tsx b/packages/journey-manager/src/ui.tsx index 675647e0..0c511e8a 100644 --- a/packages/journey-manager/src/ui.tsx +++ b/packages/journey-manager/src/ui.tsx @@ -68,6 +68,10 @@ export interface UiConfig { * Render highlights */ highlights?: boolean; + /** + * URL for the button icon + */ + iconUrl?: string; /** * UI theme */ @@ -76,10 +80,26 @@ 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 */ @@ -87,6 +107,30 @@ export interface UiConfig { sendStep: Client["sendStep"]; triggeredSteps: Array<{ stepId: string; url: string }>; }) => void; + /** + * Previous step button label + */ + previousStepButtonLabel?: string; + /** + * Custom buttons + */ + customButtons?: Array<{ + label: string; + iconUrl?: string; + onClick: () => void; + }>; + /** + * Content for the tooltip to show around the button + */ + tooltipContent?: string; + /** + * Show tooltip after a set amount of time + */ + tooltipShowAfter?: number; + /** + * Hide tooltip after a set amount of time + */ + tooltipHideAfter?: number; } const defaultTheme: Theme = { diff --git a/packages/website/src/components/Prototyping.tsx b/packages/website/src/components/Prototyping.tsx index 478e40b5..3eee72ff 100644 --- a/packages/website/src/components/Prototyping.tsx +++ b/packages/website/src/components/Prototyping.tsx @@ -118,7 +118,7 @@ const runJourneyManager = async (): Promise => { highlights: true, theme: { fontFamily: "'Neue Haas Grotesk'", - colors: { highlight: "#42f5d4" }, + colors: { highlight: "#7dd3fc" }, }, onEscalation: () => { // eslint-disable-next-line no-console From ba0a2dc8fcf8ad189f15c388bfee8fed34023d72 Mon Sep 17 00:00:00 2001 From: Michael Glass Date: Tue, 23 Jul 2024 16:51:23 +0200 Subject: [PATCH 2/4] wire up nudge tooltip --- packages/journey-manager/src/ui.tsx | 44 ++++++++++++++++--- .../website/src/components/Prototyping.tsx | 1 + 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/journey-manager/src/ui.tsx b/packages/journey-manager/src/ui.tsx index 0c511e8a..3bed5078 100644 --- a/packages/journey-manager/src/ui.tsx +++ b/packages/journey-manager/src/ui.tsx @@ -120,17 +120,18 @@ export interface UiConfig { onClick: () => void; }>; /** - * Content for the tooltip to show around the button + * 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. */ - tooltipContent?: string; + nudgeContent?: string; /** - * Show tooltip after a set amount of time + * Show nudge tooltip after this many milliseconds */ - tooltipShowAfter?: number; + nudgeShowAfterMs?: number; /** - * Hide tooltip after a set amount of time + * Hide nudge tooltip after it's been shown for this many milliseconds */ - tooltipHideAfter?: number; + nudgeHideAfterMs?: number; } const defaultTheme: Theme = { @@ -501,9 +502,39 @@ const ControlCenter: FunctionComponent<{ highlightElements: HTMLElement[]; onAction: (action: Action) => void; }> = ({ config, highlightElements, digression, onAction }) => { + 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") { @@ -524,6 +555,7 @@ const ControlCenter: FunctionComponent<{ setIsOpen((prev) => !prev); }} > + {isNudgeVisible &&
{config.nudgeContent}
}
=> { // eslint-disable-next-line no-console console.log("escalation"); }, + nudgeContent: "click me! click me!", }, triggers: triggersForRun(), onStep: (stepId) => { From 82464d708150312287ef966ca381710fbf73e50f Mon Sep 17 00:00:00 2001 From: Michael Glass Date: Tue, 23 Jul 2024 18:05:16 +0200 Subject: [PATCH 3/4] migrate pinbubble styles from chat widget --- packages/journey-manager/src/ui.tsx | 110 +++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/journey-manager/src/ui.tsx b/packages/journey-manager/src/ui.tsx index 3bed5078..e589e8a8 100644 --- a/packages/journey-manager/src/ui.tsx +++ b/packages/journey-manager/src/ui.tsx @@ -217,6 +217,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; @@ -549,17 +625,23 @@ const ControlCenter: FunctionComponent<{ return ( <> + { + setIsNudgeVisible(false); + }} + content={config.nudgeContent ?? ""} + />
{ if ( event.target != null && @@ -728,3 +810,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}
+ +
+); From 34fc51bf746111bfb41b7b32b20ee5efebdbc2ad Mon Sep 17 00:00:00 2001 From: Peter Szerzo Date: Tue, 23 Jul 2024 12:38:20 +0200 Subject: [PATCH 4/4] Improve digression detection logic --- packages/journey-manager/src/index.ts | 69 ++++----- packages/journey-manager/src/ui.tsx | 138 +++++++++++++----- .../website/src/components/Prototyping.tsx | 14 +- 3 files changed, 145 insertions(+), 76 deletions(-) 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 e589e8a8..9b7d360e 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 */ @@ -103,10 +130,7 @@ export interface UiConfig { /** * On previous step */ - onPreviousStep?: (config: { - sendStep: Client["sendStep"]; - triggeredSteps: Array<{ stepId: string; url: string }>; - }) => void; + onPreviousStep?: (config: HandlerArg) => void; /** * Previous step button label */ @@ -114,11 +138,7 @@ export interface UiConfig { /** * Custom buttons */ - customButtons?: Array<{ - label: string; - iconUrl?: string; - onClick: () => void; - }>; + 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. @@ -563,8 +583,6 @@ const Highlight: FunctionComponent<{ element: HTMLElement }> = ({ ); }; -type Action = "end" | "escalate" | "previous"; - type ControlCenterStatus = | null | "pending-escalation" @@ -574,10 +592,11 @@ 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); @@ -622,6 +641,24 @@ 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); + // Redirect to previous page if the last triggered step occurred on it + if (lastTriggeredStep.url !== window.location.toString()) { + window.location.href = lastTriggeredStep.url; + } + } + }; + return ( <> @@ -673,19 +710,19 @@ const ControlCenter: FunctionComponent<{
{config.onEscalation != null ? ( ) : null} @@ -704,7 +741,7 @@ const ControlCenter: FunctionComponent<{ ) : null} + {(config.buttons ?? []).map((buttonConfig, buttonIndex) => ( + + ))}
)}
@@ -741,21 +791,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(); + } } /** @@ -766,6 +825,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 */ @@ -779,23 +854,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, ); diff --git a/packages/website/src/components/Prototyping.tsx b/packages/website/src/components/Prototyping.tsx index 7db06486..348cc7dc 100644 --- a/packages/website/src/components/Prototyping.tsx +++ b/packages/website/src/components/Prototyping.tsx @@ -120,11 +120,21 @@ const runJourneyManager = async (): Promise => { fontFamily: "'Neue Haas Grotesk'", 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) => {