Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): add AnalyticsProvider #151

Merged
merged 1 commit into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/pages/THIRD-PARTY-NOTICES
Original file line number Diff line number Diff line change
@@ -1,3 +1,44 @@
The following NPM package may be included in this product:

- @yext/analytics@0.2.0

This package contains the following license and notice below:

The Analytics files listed in this repository are licensed under the below license. All other features and products are subject to separate agreements
and certain functionality requires paid subscriptions to Yext products.

BSD 3-Clause License

Copyright (c) 2022, Yext
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

-----------

The following NPM package may be included in this product:

- ansi-to-html@0.7.2
Expand Down
2 changes: 2 additions & 0 deletions packages/pages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"THIRD-PARTY-NOTICES"
],
"dependencies": {
"@yext/analytics": "^0.2.0",
benmcginnis marked this conversation as resolved.
Show resolved Hide resolved
"ansi-to-html": "^0.7.2",
"chalk": "^5.0.1",
"classnames": "2.3.1",
Expand Down Expand Up @@ -97,6 +98,7 @@
"@storybook/react": "^6.5.10",
"@storybook/testing-library": "^0.0.13",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.4.2",
"@types/escape-html": "^1.0.2",
benmcginnis marked this conversation as resolved.
Show resolved Hide resolved
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.30",
Expand Down
238 changes: 238 additions & 0 deletions packages/pages/src/components/analytics/Analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { TemplateProps } from "../../common/src/template/types";
import { getRuntime, isProduction } from "../../util";
import { AnalyticsMethods } from "./interfaces";
import {
ConversionDetails,
CookieManager,
EntityPage,
PagesAnalyticsService,
providePagesAnalytics,
StaticPage,
Visitor,
} from "@yext/analytics";
import { concatScopes, slugify } from "./helpers";

/**
* The Analytics class creates a stateful facade in front of the \@yext/analytics
benmcginnis marked this conversation as resolved.
Show resolved Hide resolved
* Library's pagesAnalyticsProvider class. It takes in some data from the
* template configuration and uses it to provide configuration to the
* pagesAnalyticsProvider.
*
* Additionally, it provides handlers for controlling user opt-in for compliance
* requirements as well as for debugging, enabling conversion tracking, saving
* user identity information, and creating named analytics scopes for
* easy tagging.
*
* @public
*/
export class Analytics implements AnalyticsMethods {
private _optedIn: boolean;
private _conversionTrackingEnabled = false;
private _cookieManager: CookieManager | undefined;
private _analyticsReporter: PagesAnalyticsService | undefined;
private _pageViewFired = false;
private _enableDebugging = false;

/**
* Creates an Analytics instance, will fire a pageview event if requireOptin
* is false
*
* @param templateData - template data object from the pages system
* @param requireOptIn - boolean, set to true if you require user opt in before tracking analytics
*/
constructor(
private templateData: TemplateProps,
requireOptIn?: boolean | undefined
) {
this._optedIn = !requireOptIn;
this.makeReporter();
this.pageView();
benmcginnis marked this conversation as resolved.
Show resolved Hide resolved
}

private calculatePageType(): EntityPage | StaticPage {
const isStaticPage = !!this.templateData.document?.__?.staticPage;
const isEntityPage = !!this.templateData.document?.__?.entityPageSet;

let pageType: EntityPage | StaticPage;

if (isStaticPage) {
pageType = {
name: "static",
staticPageId: this.templateData.document.__.name as string,
benmcginnis marked this conversation as resolved.
Show resolved Hide resolved
};
} else if (isEntityPage) {
pageType = {
name: "entity",
pageSetId: this.templateData.document.__.name as string,
id: this.templateData.document.uid as number,
};
} else {
throw new Error("invalid document type");
}

return pageType;
}

private makeReporter() {
if (getRuntime().name !== "browser") {
return;
}
if (!this._optedIn) {
return;
}

const inProduction =
isProduction(this.templateData?.document?.siteInternalHostName) ||
bhainesva marked this conversation as resolved.
Show resolved Hide resolved
isProduction(this.templateData?.document?.siteDomain);

this._analyticsReporter = providePagesAnalytics({
businessId: this.templateData.document.businessId as number,
pageType: this.calculatePageType(),
pageUrl: window.location.href,
production: inProduction,
referrer: document.referrer,
siteId: this.templateData.document.siteId as number,
});

this.setDebugEnabled(this._enableDebugging);
}

private canTrack(): boolean {
return (
getRuntime().name === "browser" &&
this._optedIn &&
!!this._analyticsReporter
);
}

private setupConversionTracking(): void {
this._cookieManager = new CookieManager();
this._analyticsReporter?.setConversionTrackingEnabled(
true,
this._cookieManager.setAndGetYextCookie()
);
}

/** {@inheritDoc AnalyticsMethods.enableConversionTracking} */
enableTrackingCookie(): void {
this._conversionTrackingEnabled = true;

if (this.canTrack()) {
this.setupConversionTracking();
}
}

/** {@inheritDoc AnalyticsMethods.identify} */
identify(visitor: Visitor): void {
if (this.canTrack()) {
this._analyticsReporter?.setVisitor(visitor);
}
}

/** {@inheritDoc AnalyticsMethods.async} */
async optIn(): Promise<void> {
this._optedIn = true;
this.makeReporter();
if (this._conversionTrackingEnabled && !this._cookieManager) {
this.setupConversionTracking();
}
if (!this._pageViewFired) {
await this.pageView();
}
}

/** {@inheritDoc AnalyticsMethods.async} */
async pageView(): Promise<void> {
if (!this.canTrack()) {
return Promise.resolve(undefined);
}
// TODO: if this successfully completes & conversion tracking is enabled
// and the user is opted in we should remove the y_source query parameter
// from the url if it is present to prevent double counting a listings click
// if the page is refreshed.
await this._analyticsReporter?.pageView();
this._pageViewFired = true;
}

/** {@inheritDoc AnalyticsMethods.track} */
async track(
eventName: string,
conversionData?: ConversionDetails
): Promise<void> {
if (!this.canTrack()) {
return Promise.resolve();
}

await this._analyticsReporter?.track(
{ eventType: slugify(eventName) },
conversionData
);
}

/** {@inheritDoc AnalyticsMethods.setDebugEnabled} */
setDebugEnabled(enabled: boolean): void {
this._enableDebugging = enabled;
this._analyticsReporter?.setDebugEnabled(enabled);
}

/** {@inheritDoc AnalyticsMethods.trackClick} */
trackClick(
eventName: string,
conversionData?: ConversionDetails
): (e: MouseEvent) => Promise<void> {
return (e: MouseEvent) => {
if (!this.canTrack()) {
return Promise.resolve();
}

if (e.target === null || e.defaultPrevented) {
return this.track(eventName, conversionData);
}

const targetLink = e.target as HTMLAnchorElement;

if (targetLink.href === null || targetLink.href === undefined) {
return this.track(eventName, conversionData);
}

const linkUrl = new URL(targetLink.href);

if (
linkUrl.protocol === "mailto:" ||
linkUrl.protocol === "tel:" ||
linkUrl.protocol === "javascript:" ||
linkUrl.hostname === window.location.hostname
) {
return this.track(eventName, conversionData);
}

const targetBlankOrSimilar =
(targetLink.target &&
!targetLink.target.match(/^_(self|parent|top)$/i)) ||
e.ctrlKey ||
e.shiftKey ||
e.metaKey;

if (targetBlankOrSimilar) {
return this.track(eventName, conversionData);
}

e.preventDefault();

const navigate = () => {
window.location.href = linkUrl.toString();
};

const awaitTimeout = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});

return Promise.race([
this.track(eventName, conversionData),
awaitTimeout,
]).then(navigate);
};
}
}
Loading