diff --git a/packages/client/src/analytics/task-analytics/ta-matching.ts b/packages/client/src/analytics/task-analytics/ta-matching.ts index 46d10419..8972bdf9 100644 --- a/packages/client/src/analytics/task-analytics/ta-matching.ts +++ b/packages/client/src/analytics/task-analytics/ta-matching.ts @@ -28,13 +28,9 @@ const isMatchingUrl = ( match: TaskAnalyticsUrlRule["match"], ) => (match === "startsWith" ? currentUrl.startsWith(url) : currentUrl === url); -const isMatchingUrls = (urls?: TaskAnalyticsUrlRule[]) => { - if (!urls) { - return true; - } - - const currentUrl = removeTrailingSlash( - `${window.location.origin}${window.location.pathname}`, +const isMatchingUrls = (urls: TaskAnalyticsUrlRule[], currentUrl: URL) => { + const currentUrlStr = removeTrailingSlash( + `${currentUrl.origin}${currentUrl.pathname}`, ); let isMatched: boolean | null = null; @@ -44,7 +40,7 @@ const isMatchingUrls = (urls?: TaskAnalyticsUrlRule[]) => { const { url, match, exclude } = urlRule; const urlToMatch = removeTrailingSlash(url); - if (isMatchingUrl(urlToMatch, currentUrl, match)) { + if (isMatchingUrl(urlToMatch, currentUrlStr, match)) { // If the url is excluded we can stop. If not, we need to continue checking the url-array, in case // there are exclusions in the rest of the array if (exclude) { @@ -89,11 +85,12 @@ export const taskAnalyticsIsMatchingSurvey = ( survey: TaskAnalyticsSurveyConfig, currentLanguage: Language, currentAudience: Audience, + currentUrl: URL, ) => { const { urls, audience, language, duration } = survey; return ( - isMatchingUrls(urls) && + (!urls || isMatchingUrls(urls, currentUrl)) && isMatchingAudience(currentAudience, audience) && isMatchingLanguage(currentLanguage, language) && isMatchingDuration(duration) @@ -104,6 +101,7 @@ export const taskAnalyticsGetMatchingSurveys = ( surveys: TaskAnalyticsSurveyConfig[], currentLanguage: Language, currentAudience: Context, + currentUrl: URL, ) => { const { matched: prevMatched = {} } = taskAnalyticsGetState(); @@ -122,6 +120,7 @@ export const taskAnalyticsGetMatchingSurveys = ( survey, currentLanguage, currentAudience, + currentUrl, ); if (!isMatching) { return false; diff --git a/packages/client/src/analytics/task-analytics/ta.ts b/packages/client/src/analytics/task-analytics/ta.ts index d89b7ddf..024c3bdb 100644 --- a/packages/client/src/analytics/task-analytics/ta.ts +++ b/packages/client/src/analytics/task-analytics/ta.ts @@ -28,11 +28,17 @@ const startSurveyIfMatching = ( surveys: TaskAnalyticsSurveyConfig[], currentLanguage: Language, currentAudience: Context, + currentUrl: URL, ) => { const survey = surveys.find((s) => s.id === surveyId); if ( survey && - taskAnalyticsIsMatchingSurvey(survey, currentLanguage, currentAudience) + taskAnalyticsIsMatchingSurvey( + survey, + currentLanguage, + currentAudience, + currentUrl, + ) ) { startSurvey(surveyId); } @@ -41,6 +47,7 @@ const startSurveyIfMatching = ( const findAndStartSurvey = ( surveys: TaskAnalyticsSurveyConfig[], state: AppState, + currentUrl: URL, ) => { const { params } = state; // const { context } = state.params @@ -58,6 +65,7 @@ const findAndStartSurvey = ( surveys, params.language, params.context, + currentUrl, ); return; } @@ -66,6 +74,7 @@ const findAndStartSurvey = ( surveys, params.language, params.context, + currentUrl, ); if (!matchingSurveys) { return; @@ -81,7 +90,7 @@ const findAndStartSurvey = ( startSurvey(id); }; -const fetchAndStart = async (state: AppState) => { +const fetchAndStart = async (state: AppState, currentUrl: URL) => { return fetch(`${state.env.APP_URL}/api/ta`) .then((res) => { if (!res.ok) { @@ -97,25 +106,31 @@ const fetchAndStart = async (state: AppState) => { ); } fetchedSurveys = surveys; - findAndStartSurvey(surveys, state); + findAndStartSurvey(surveys, state, currentUrl); }) .catch((e) => { console.error(`Error fetching Task Analytics surveys - ${e}`); }); }; -const startTaskAnalyticsSurvey = (state: AppState) => { +export const startTaskAnalyticsSurvey = ( + state: AppState, + currentUrl = new URL(window.location.href), +) => { taskAnalyticsRefreshState(); if (fetchedSurveys) { - findAndStartSurvey(fetchedSurveys, state); + findAndStartSurvey(fetchedSurveys, state, currentUrl); } else { - fetchAndStart(state); + fetchAndStart(state, currentUrl); } }; export const initTaskAnalytics = () => { window.TA = window.TA || taFallback; window.dataLayer = window.dataLayer || []; - window.startTaskAnalyticsSurvey = startTaskAnalyticsSurvey; + + window.addEventListener("historyPush", (e) => + startTaskAnalyticsSurvey(window.__DECORATOR_DATA__, e.detail.url), + ); }; diff --git a/packages/client/src/client.d.ts b/packages/client/src/client.d.ts index 247e5a21..6f2eeb4c 100644 --- a/packages/client/src/client.d.ts +++ b/packages/client/src/client.d.ts @@ -20,7 +20,6 @@ declare global { eventData: Record, origin?: string, ) => void; - startTaskAnalyticsSurvey: (state: AppState) => void; // For task analytics, should have better types? TA: any; dataLayer: any; diff --git a/packages/client/src/events.ts b/packages/client/src/events.ts index 1c9dce97..53a7b0f2 100644 --- a/packages/client/src/events.ts +++ b/packages/client/src/events.ts @@ -18,6 +18,9 @@ export type CustomEvents = { menuclosed: void; clearsearch: void; closemenus: void; // Currently fired only from other apps + historyPush: { + url: URL; + }; }; export type MessageEvents = @@ -43,3 +46,44 @@ export function createEvent( export const analyticsReady = new CustomEvent("analytics-ready-event", { bubbles: true, }); + +type PushStateArgs = Parameters; +type ReplaceStateArgs = Parameters; + +// Emits events on navigation in SPAs +export const initHistoryEvents = () => { + const pushStateActual = window.history.pushState.bind(window.history); + const replaceStateActual = window.history.replaceState.bind(window.history); + + let currentPathname = window.location.pathname; + + const dispatchHistoryEvent = (url?: URL | string | null) => { + if (!url) { + return; + } + + const urlParsed = new URL(url, window.location.origin); + const newPathname = urlParsed.pathname; + + if (newPathname !== currentPathname) { + dispatchEvent( + createEvent("historyPush", { + detail: { + url: urlParsed, + }, + }), + ); + currentPathname = newPathname; + } + }; + + window.history.pushState = (...args: PushStateArgs) => { + dispatchHistoryEvent(args[2]); + return pushStateActual(...args); + }; + + window.history.replaceState = (...args: ReplaceStateArgs) => { + dispatchHistoryEvent(args[2]); + return replaceStateActual(...args); + }; +}; diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index e1b864e4..4a59657b 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -28,11 +28,13 @@ import "./views/chatbot-wrapper"; import "./views/sticky"; import "./views/user-menu"; import { addFaroMetaData } from "./faro"; -import { analyticsReady, createEvent } from "./events"; +import { analyticsReady, createEvent, initHistoryEvents } from "./events"; import { type ParamKey } from "decorator-shared/params"; import { param, hasParam, updateDecoratorParams, env } from "./params"; import { makeEndpointFactory } from "decorator-shared/urls"; import { initAnalytics } from "./analytics/analytics"; +import { logPageView } from "./analytics/amplitude"; +import { startTaskAnalyticsSurvey } from "./analytics/task-analytics/ta"; import.meta.glob("./styles/*.css", { eager: true }); @@ -125,6 +127,7 @@ window.addEventListener("activecontext", (event) => { }); const init = async () => { + initHistoryEvents(); initAnalytics(); api.checkAuth().then((authResponse) => { @@ -135,11 +138,22 @@ const init = async () => { }; window.addEventListener(analyticsReady.type, () => { - window.startTaskAnalyticsSurvey(window.__DECORATOR_DATA__); + startTaskAnalyticsSurvey(window.__DECORATOR_DATA__); }); window.addEventListener("authupdated", (e) => { - window.logPageView(window.__DECORATOR_DATA__.params, e.detail.auth); + const { auth } = e.detail; + + window.logPageView(window.__DECORATOR_DATA__.params, auth); + + window.addEventListener("historyPush", () => + // TODO: can this be solved in a more dependable manner? + // setTimeout to ensure window.location is updated after the history push + setTimeout( + () => logPageView(window.__DECORATOR_DATA__.params, auth), + 250, + ), + ); }); // @TODO: Refactor loaders diff --git a/packages/client/src/views/ops-messages.ts b/packages/client/src/views/ops-messages.ts index 3b160a37..b4581aa8 100644 --- a/packages/client/src/views/ops-messages.ts +++ b/packages/client/src/views/ops-messages.ts @@ -29,25 +29,36 @@ export const OpsMessagesTemplate = ({ `; +const exactPathTerminator = "$"; + const removeTrailingChars = (url?: string) => - url?.replace(/(\/|\$|(\/\$))$/, ""); + url?.replace(`${exactPathTerminator}$`, "").replace(/\/$/, ""); + +// url?.replace(new RegExp(`/?${exactPathTerminator}?$`), ""); class OpsMessages extends HTMLElement { private messages: OpsMessage[] = []; - connectedCallback() { + private connectedCallback() { fetch(`${env("APP_URL")}/ops-messages`) .then((res) => res.json()) .then((opsMessages) => { this.messages = opsMessages; this.render(); }); + + window.addEventListener("historyPush", (e) => + this.render(e.detail.url), + ); + window.addEventListener("popstate", () => this.render()); } - render() { + private render(url?: URL) { const filteredMessages = this.messages.filter( (opsMessage: OpsMessage) => { - const currentUrl = removeTrailingChars(window.location.href); + const currentUrl = removeTrailingChars( + (url ?? window.location).href, + ); return ( !opsMessage.urlscope || !currentUrl || @@ -56,7 +67,7 @@ class OpsMessages extends HTMLElement { const url = removeTrailingChars(rawUrl); return ( url && - (rawUrl.endsWith("$") + (rawUrl.endsWith(exactPathTerminator) ? currentUrl === url : currentUrl.startsWith(url)) );