(core) Update HelpScout beacon to work with embedded documentation articles.

Summary:
- Fix base href in HelpScout beacon when showing articles (in particular for Firefox)
- Show the 'Answers' tab normally except when reporting an error.
- Combine the "Give Feedback" and "Help Center" buttons into one that normally
  opens the beacon (with a link to Help Center and to Community Forum), and a
  smaller one that opens the Help Center site in a new tab.
- Update HELP_SCOUT_* env vars to use _V2 suffix, to allow them to coexist with
  code using the previous beacon.

Test Plan: Updated the browser test to check the new behavior.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3170
This commit is contained in:
Dmitry S 2021-12-07 00:37:37 -05:00
parent 6b448567c9
commit 8100272e9a
5 changed files with 61 additions and 19 deletions

View File

@ -29,6 +29,8 @@ export type BeaconCmd = 'init' | 'destroy' | 'open' | 'close' | 'toggle' | 'sear
'article' | 'navigate' | 'identify' | 'prefill' | 'reset' | 'logout' | 'config' | 'on' | 'off' | 'article' | 'navigate' | 'identify' | 'prefill' | 'reset' | 'logout' | 'config' | 'on' | 'off' |
'once' | 'event' | 'session-data'; 'once' | 'event' | 'session-data';
export type BeaconRoute = '/ask/message/' | '/answers/';
export interface IUserObj { export interface IUserObj {
name?: string; name?: string;
email?: string; email?: string;
@ -70,6 +72,8 @@ export function Beacon(method: BeaconCmd, options?: unknown, data?: unknown) {
(window as any).Beacon(method, options, data); (window as any).Beacon(method, options, data);
} }
// This is essentially what's done by the code snippet that HelpScout suggests to install in every
// page. In Grist app pages, we only load HelpScout code when the beacon is opened.
function _beacon(method: BeaconCmd, options?: unknown, data?: unknown) { function _beacon(method: BeaconCmd, options?: unknown, data?: unknown) {
_beacon.readyQueue.push({method, options, data}); _beacon.readyQueue.push({method, options, data});
} }
@ -103,7 +107,7 @@ let lastOpenType: 'error' | 'message' = 'message';
* If errors is given, prepares a form for submitting an error report, and includes stack traces * If errors is given, prepares a form for submitting an error report, and includes stack traces
* into the session-data. * into the session-data.
*/ */
function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, errors?: IAppError[]}) { function _beaconOpen(userObj: IUserObj|null, options: IBeaconOpenOptions) {
const {onOpen, errors} = options; const {onOpen, errors} = options;
// The beacon remembers its content, so reset it when switching between reporting errors and // The beacon remembers its content, so reset it when switching between reporting errors and
@ -119,6 +123,16 @@ function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, erro
if (iframe) { iframe.focus(); } if (iframe) { iframe.focus(); }
if (onOpen) { onOpen(); } if (onOpen) { onOpen(); }
}); });
Beacon('once', 'article-viewed' as any, () => {
// HelpScout creates an iframe with an empty 'src' attribute, then writes to it. In such an
// iframe, different browsers interpret relative links differently: Chrome's are relative to
// the parent page's URL; Firefox's are relative to the parent page's <base href>.
//
// Here we set a <base href> explicitly in the iframe to get consistent behavior of links
// relative to the top page's URL (HelpScout then seems to handle clicks on them correctly).
const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement;
iframe?.contentDocument?.head.appendChild(dom('base', {href: ''}));
});
Beacon('once', 'close', () => { Beacon('once', 'close', () => {
const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement; const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement;
if (iframe) { iframe.blur(); } if (iframe) { iframe.blur(); }
@ -128,6 +142,7 @@ function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, erro
} }
const attrs: ISessionData = {}; const attrs: ISessionData = {};
let route: BeaconRoute;
if (errors?.length) { if (errors?.length) {
// If sending errors, prefill part of the message (the user sees this and can add to it), and // If sending errors, prefill part of the message (the user sees this and can add to it), and
// include more detailed errors with stack traces into session-data. // include more detailed errors with stack traces into session-data.
@ -147,8 +162,10 @@ function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, erro
attrs[`error-${i}-stack`] = JSON.stringify(error.stack.trim().split('\n')); attrs[`error-${i}-stack`] = JSON.stringify(error.stack.trim().split('\n'));
} }
}); });
route = options.route || '/ask/message/';
} else { } else {
Beacon('config', {messaging: {contactForm: {showSubject: true}}}); Beacon('config', {messaging: {contactForm: {showSubject: true}}});
route = options.route || '/answers/';
} }
Beacon('session-data', { Beacon('session-data', {
@ -156,7 +173,7 @@ function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, erro
...attrs, ...attrs,
}); });
Beacon('open'); Beacon('open');
Beacon('navigate', '/ask/message/'); Beacon('navigate', route);
} }
export interface IBeaconOpenOptions { export interface IBeaconOpenOptions {
@ -164,6 +181,7 @@ export interface IBeaconOpenOptions {
includeAppErrors?: boolean; includeAppErrors?: boolean;
onOpen?: () => void; onOpen?: () => void;
errors?: IAppError[]; errors?: IAppError[];
route?: BeaconRoute;
} }
/** /**

View File

@ -25,23 +25,22 @@ import {dom, DomContents, Observable, styled} from 'grainjs';
* HelpCenter in a new tab. * HelpCenter in a new tab.
*/ */
export function createHelpTools(appModel: AppModel, spacer = true): DomContents { export function createHelpTools(appModel: AppModel, spacer = true): DomContents {
const isEfcr = (appModel.topAppModel.productFlavor === 'efcr');
return [ return [
spacer ? cssSpacer() : null, spacer ? cssSpacer() : null,
cssPageEntry( cssSplitPageEntry(
cssPageLink(cssPageIcon('Feedback'), cssPageEntryMain(
cssLinkText('Give Feedback'), cssPageLink(cssPageIcon('Help'),
dom.on('click', () => beaconOpenMessage({appModel})), cssLinkText('Help Center'),
dom.cls('tour-help-center'),
dom.on('click', (ev) => beaconOpenMessage({appModel})),
testId('left-feedback'),
),
), ),
dom.hide(isEfcr), cssPageEntrySmall(
testId('left-feedback'), cssPageLink(cssPageIcon('FieldLink'),
), {href: commonUrls.help, target: '_blank'},
cssPageEntry( ),
cssPageLink(cssPageIcon('Help'), {href: commonUrls.help, target: '_blank'}, )
cssLinkText('Help Center'),
dom.cls('tour-help-center')
),
dom.hide(isEfcr),
), ),
]; ];
} }
@ -156,3 +155,28 @@ export const cssPageIcon = styled(icon, `
export const cssSpacer = styled('div', ` export const cssSpacer = styled('div', `
height: 18px; height: 18px;
`); `);
const cssSplitPageEntry = styled('div', `
display: flex;
align-items: center;
`);
const cssPageEntryMain = styled(cssPageEntry, `
flex: auto;
margin: 0;
`);
const cssPageEntrySmall = styled(cssPageEntry, `
flex: none;
border-radius: 3px;
--icon-color: ${colors.lightGreen};
& > .${cssPageLink.className} {
padding: 0 8px 0 16px;
}
&:hover {
--icon-color: ${colors.darkGreen};
}
.${cssTools.className}-collapsed & {
display: none;
}
`);

View File

@ -145,7 +145,7 @@ function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel:
cssDropdownFeedbackLink( cssDropdownFeedbackLink(
cssDropdownFeedbackIcon('Feedback'), cssDropdownFeedbackIcon('Feedback'),
'Give feedback', 'Give feedback',
dom.on('click', () => beaconOpenMessage({appModel, onOpen: () => ctl.close()})), dom.on('click', () => beaconOpenMessage({appModel, onOpen: () => ctl.close(), route: '/ask/message/'})),
testId('feedback'), testId('feedback'),
) )
), ),

View File

@ -39,7 +39,7 @@ export function getOrgFromRequest(req: Request): string|null {
* HelpScout the user identity for identifying customer information and conversation history. * HelpScout the user identity for identifying customer information and conversation history.
*/ */
function helpScoutSign(email: string): string|undefined { function helpScoutSign(email: string): string|undefined {
const secretKey = process.env.HELP_SCOUT_SECRET_KEY; const secretKey = process.env.HELP_SCOUT_SECRET_KEY_V2;
if (!secretKey) { return undefined; } if (!secretKey) { return undefined; }
return crypto.createHmac('sha256', secretKey).update(email).digest('hex'); return crypto.createHmac('sha256', secretKey).update(email).digest('hex');
} }

View File

@ -41,7 +41,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
stripeAPIKey: process.env.STRIPE_PUBLIC_API_KEY, stripeAPIKey: process.env.STRIPE_PUBLIC_API_KEY,
googleClientId: process.env.GOOGLE_CLIENT_ID, googleClientId: process.env.GOOGLE_CLIENT_ID,
googleDriveScope: process.env.GOOGLE_DRIVE_SCOPE, googleDriveScope: process.env.GOOGLE_DRIVE_SCOPE,
helpScoutBeaconId: process.env.HELP_SCOUT_BEACON_ID, helpScoutBeaconId: process.env.HELP_SCOUT_BEACON_ID_V2,
maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined, maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined, maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
timestampMs: Date.now(), timestampMs: Date.now(),