diff --git a/src/common/constants.js b/src/common/constants.js index 9d60ddc..c41f909 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -1,3 +1,6 @@ +import packageJson from '../../package.json'; + +export const OCL_CLIENT = `oclmap/${packageJson.version}`; export const LANGUAGES = [ {locale: 'en', name: 'English'}, {locale: 'es', name: "EspaƱol"}, diff --git a/src/common/utils.js b/src/common/utils.js index 1ccd5cd..b57da9a 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -1,19 +1,19 @@ /*eslint no-process-env: 0*/ import 'core-js/features/url-search-params'; import React from 'react'; -import ReactGA from 'react-ga4'; import moment from 'moment'; import { Tooltip } from '@mui/material'; import { filter, difference, compact, find, reject, intersectionBy, size, keys, omitBy, isEmpty, get, includes, map, isArray, values, pick, sortBy, zipObject, orderBy, isObject, merge, - uniqBy, cloneDeep, isEqual, without, capitalize, last, nth, startCase, uniq, flatten, pickBy, upperFirst + uniqBy, cloneDeep, isEqual, without, capitalize, last, nth, uniq, flatten, pickBy, upperFirst } from 'lodash'; import { DATE_FORMAT, TIME_FORMAT, DATETIME_FORMAT, OCL_SERVERS_GROUP, OCL_FHIR_SERVERS_GROUP, HAPI_FHIR_SERVERS_GROUP, OPENMRS_URL, DEFAULT_FHIR_SERVER_FOR_LOCAL_ID, OPERATIONS_PANEL_GROUP } from './constants'; import APIService from '../services/APIService'; +import GAService from '../services/GAService'; import { SERVER_CONFIGS } from './serverConfigs'; export const currentPath = () => window.location.hash.split('?')[0]; @@ -561,32 +561,9 @@ export const getOpenMRSURL = () => { return OPENMRS_URL.replace('openmrs.', `openmrs.${env}`); } -export const recordGAPageView = () => { - /*eslint no-undef: 0*/ - ReactGA.initialize(window.GA_ACCOUNT_ID || process.env.GA_ACCOUNT_ID); - // Strip the query string from the hash so auth params (code, state, session_state) on the OIDC callback are not sent to GA - ReactGA.send({ hitType: "pageview", page: window.location.pathname + window.location.hash.split('?')[0] }); -} - -export const recordGAAction = (category, action, label) => { - /*eslint no-undef: 0*/ - if(category && action) { - ReactGA.initialize(window.GA_ACCOUNT_ID || process.env.GA_ACCOUNT_ID); - ReactGA.event({category: category, action: action, label: label || action, transport: "xhr"}); - } -} - -export const recordGAUpsertEvent = (category, edit, resource) => { - const actionPrefix = edit ? 'update' : 'create' - resource = resource || category.replaceAll(' ', '_').toLowerCase() - let action = `${actionPrefix}_${resource}` - let label = `${startCase(actionPrefix)} ${startCase(resource)}` - recordGAAction(category, action, label) -} - export const setUpRecentHistory = history => { history.listen(location => { - recordGAPageView() + GAService.recordPageView() let visits = JSON.parse(get(localStorage, 'visits', '[]')); let urlParts = compact(location.pathname.split('/')); let type = ''; @@ -873,6 +850,9 @@ export const getLoginURL = async returnTo => { const codeChallenge = await preparePKCECodeChallenge() const state = prepareOAuthState() const nonce = generateSecureRandomString(32) + + GAService.clearSignupFlow() + return `${getAPIURL()}/users/login/?client_id=${oidClientID}&state=${state}&nonce=${nonce}&redirect_uri=${redirectURL}&code_challenge=${codeChallenge}&code_challenge_method=S256` } @@ -895,6 +875,9 @@ export const getRegisterURL = async returnTo => { const codeChallenge = await preparePKCECodeChallenge() const state = prepareOAuthState() const nonce = generateSecureRandomString(32) + + GAService.recordSignupStart() + return `${getAPIURL()}/users/signup/?client_id=${oidClientID}&state=${state}&nonce=${nonce}&redirect_uri=${redirectURL}&code_challenge=${codeChallenge}&code_challenge_method=S256` } diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index c228fb9..e9c0b4c 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -2,9 +2,10 @@ import React from 'react'; import { Route, Switch, withRouter } from 'react-router-dom'; import { - recordGAPageView, isLoggedIn, getLoginURL, isOtherOCLClientURL, isRedirectingToLoginViaReferrer, + isLoggedIn, getLoginURL, isOtherOCLClientURL, isRedirectingToLoginViaReferrer, isInWaitlist, getEnv } from '../../common/utils'; +import GAService from '../../services/GAService'; import Error404 from '../errors/Error404'; import Error403 from '../errors/Error403'; import Error401 from '../errors/Error401'; @@ -108,7 +109,7 @@ const App = props => { forceLoginUser() fetchToggles() addLogoutListenerForAllTabs() - recordGAPageView() + GAService.recordPageView() setupHotJar() return () => unsubscribe() diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx index 7aed0f6..dbc267f 100644 --- a/src/components/users/OIDLoginCallback.jsx +++ b/src/components/users/OIDLoginCallback.jsx @@ -5,6 +5,7 @@ import { refreshCurrentUserCache, consumeStoredPKCECodeVerifier, consumeAndValidateOAuthState } from '../../common/utils'; import APIService from '../../services/APIService' +import GAService from '../../services/GAService' import { OperationsContext } from '../app/LayoutContext'; class OIDLoginCallback extends React.Component { @@ -39,6 +40,7 @@ class OIDLoginCallback extends React.Component { APIService.users().appendToUrl('oidc/code-exchange/').post({code: code, redirect_uri: redirectURL, client_id: clientId, code_verifier: codeVerifier}).then(res => { if(res.data?.access_token) { + GAService.recordSignupComplete() localStorage.removeItem('server_configs') localStorage.setItem('token', res.data.access_token) localStorage.setItem('id_token', res.data.id_token) @@ -51,6 +53,7 @@ class OIDLoginCallback extends React.Component { }) this.cacheUserData() } else { + GAService.clearSignupFlow() setAlert({severity: 'error', message: res.data?.error_description || this.props.t('auth.sign_in_error')}) } }) diff --git a/src/services/APIService.js b/src/services/APIService.js index 9b26b26..847f8ae 100644 --- a/src/services/APIService.js +++ b/src/services/APIService.js @@ -2,8 +2,7 @@ import axios from 'axios'; import {get, omit, isPlainObject, isString, defaults } from 'lodash'; import { currentUserToken, getAPIURL, logoutUser, sleep } from '../common/utils'; - -import packageJson from '../../package.json'; +import { OCL_CLIENT } from '../common/constants'; const APIServiceProvider = {}; @@ -177,7 +176,7 @@ class APIService { const obj = defaults(headers, this.headers); if (token) obj['Authorization'] = `Token ${token}`; obj['INCLUDESEARCHLATEST'] = true - obj['X-OCL-CLIENT'] = `oclmap/${packageJson.version}`; + obj['X-OCL-CLIENT'] = OCL_CLIENT; return obj; } diff --git a/src/services/GAService.js b/src/services/GAService.js new file mode 100644 index 0000000..43ebf01 --- /dev/null +++ b/src/services/GAService.js @@ -0,0 +1,65 @@ +/*eslint no-process-env: 0*/ +import ReactGA from 'react-ga4'; +import { startCase } from 'lodash'; +import { OCL_CLIENT } from '../common/constants'; + +const SIGNUP_FLOW_PENDING_KEY = 'signup_flow_pending'; + +const gaId = () => window.GA_ACCOUNT_ID || process.env.GA_ACCOUNT_ID; + +// The marketing site only exists in prod, at these two domains, regardless +// of which env this app itself is running in. v3./app. mirror this app's +// own env (e.g. map.qa. -> app.v3.qa., map.qa. -> app.qa.). +const linkedDomains = () => { + const host = window.location.host; + + return [ + 'openconceptlab.org', + 'preview.openconceptlab.org', + host.replace('map.', 'app.v3.'), + host.replace('map.', 'app.'), + ]; +}; + +const initialize = options => { + /*eslint no-undef: 0*/ + ReactGA.initialize(gaId(), options); +}; + +const GAService = { + recordPageView() { + initialize({ gtagOptions: { linker: { domains: linkedDomains() } } }); + ReactGA.send({ hitType: 'pageview', page: window.location.pathname + window.location.hash.split('?')[0] }); + }, + + recordUpsertEvent(category, edit, resource) { + const actionPrefix = edit ? 'update' : 'create'; + resource = resource || category.replaceAll(' ', '_').toLowerCase(); + const action = `${actionPrefix}_${resource}`; + const label = `${startCase(actionPrefix)} ${startCase(resource)}`; + this.recordEvent(action, { event_category: category, event_label: label }); + }, + + recordSignupStart() { + sessionStorage.setItem(SIGNUP_FLOW_PENDING_KEY, '1'); + this.recordEvent('signup_start', { event_category: 'auth', event_label: 'signup_start' }); + }, + + clearSignupFlow() { + sessionStorage.removeItem(SIGNUP_FLOW_PENDING_KEY); + }, + + recordSignupComplete() { + const pending = sessionStorage.getItem(SIGNUP_FLOW_PENDING_KEY) === '1'; + this.clearSignupFlow(); + if(pending) + this.recordEvent('signup_complete', { event_category: 'auth', event_label: 'signup_complete' }); + }, + + recordEvent(name, params) { + initialize(); + ReactGA.event(name, { client: OCL_CLIENT, ...params }); + }, +}; + +export default GAService;