diff --git a/packages/core/src/analytics/utils/getters.js b/packages/core/src/analytics/utils/getters.js index f5600fafe..b824d8a4a 100644 --- a/packages/core/src/analytics/utils/getters.js +++ b/packages/core/src/analytics/utils/getters.js @@ -66,6 +66,10 @@ export const getLocation = data => { }; export const getCookie = name => { + if (typeof document === 'undefined') { + return; + } + const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); diff --git a/packages/react/src/analytics/analytics.js b/packages/react/src/analytics/analytics.js index 1d2dfb7ca..5a0bf5f39 100644 --- a/packages/react/src/analytics/analytics.js +++ b/packages/react/src/analytics/analytics.js @@ -1,4 +1,7 @@ -import { name as PCKG_NAME, version as PCKG_VERSION } from '../../package.json'; +import { + PACKAGE_NAME as PCKG_NAME, + PACKAGE_NAME_VERSION as PCKG_VERSION, +} from './constants'; import Analytics, { trackTypes as analyticsTrackTypes, platformTypes, diff --git a/packages/react/src/analytics/constants.js b/packages/react/src/analytics/constants.js new file mode 100644 index 000000000..4273c96cb --- /dev/null +++ b/packages/react/src/analytics/constants.js @@ -0,0 +1,9 @@ +// We use a require here to avoid typescript complaining of `package.json` is not +// under rootDir that we would get if we used an import. Typescript apparently ignores +// requires. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { name, version } = require('../../package.json'); + +export const PACKAGE_VERSION = version; +export const PACKAGE_NAME = name; +export const PACKAGE_NAME_VERSION = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; diff --git a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/GoogleConsentMode.js b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/GoogleConsentMode.js index ef2b12ec4..bd846d8a4 100644 --- a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/GoogleConsentMode.js +++ b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/GoogleConsentMode.js @@ -1,144 +1,213 @@ -/** - * GoogleConsentMode handles with Google Consent Mode v2. - */ -import isEqual from 'lodash/isEqual'; +import { GCM_SHARED_COOKIE_NAME, setCookie } from './cookieUtils'; +import { utils } from '@farfetch/blackout-core/analytics'; import omit from 'lodash/omit'; export const googleConsentTypes = { GRANTED: 'granted', DENIED: 'denied', }; + +/** + * GoogleConsentMode handles with Google Consent Mode v2. + */ export class GoogleConsentMode { + dataLayer; // Stores different data layer names + configWithConsentOnly; // exclude not consent properties from config + consentDataLayerCommands = []; + waitForUpdate; + regions; + hasConfig; + /** * Creates a new GoogleConsentMode instance. * * @param {string} dataLayer - DataLayer name. - * @param {string} initConsent - The init consent data. + * @param {object} initConsent - The init consent data. * @param {object} config - The configuration properties of Google Consent Mode. */ constructor(dataLayer, initConsent, config) { this.dataLayer = dataLayer; - this.config = config; + + this.waitForUpdate = config?.waitForUpdate; + this.regions = config?.regions; // select only the Google Consent Elements - this.configExcludingModeRegionsAndWaitForUpdate = omit(this.config || {}, [ + this.configWithConsentOnly = omit(config || {}, [ 'waitForUpdate', 'regions', 'mode', ]); - this.loadDefaults(initConsent); + this.hasConfig = Object.keys(this.configWithConsentOnly).length > 0; + + this.initialize(initConsent); } /** - * Initialize Google Consent Mode instance. - * - * @param {string} initConsent - The init consent data. + * Tries to load shared consent from cookies if available + * and writes it to the dataLayer. + * This method is only supposed to be called if no google + * consent config was passed. */ - loadDefaults(initConsent) { - if (this.config) { - const initialValue = {}; + loadSharedConsentFromCookies() { + const consentModeCookieValue = utils.getCookie(GCM_SHARED_COOKIE_NAME); - if (this.config.waitForUpdate) { - initialValue['wait_for_update'] = this.config.waitForUpdate; - } + if (consentModeCookieValue) { + try { + const values = JSON.parse(consentModeCookieValue); + + if (Array.isArray(values)) { + values.forEach(value => { + const [consentCommand, command, consent] = value; - // Obtain default google consent registry - const consentRegistry = Object.keys( - this.configExcludingModeRegionsAndWaitForUpdate, - ).reduce((result, consentKey) => { - return { - ...result, - [consentKey]: - this.configExcludingModeRegionsAndWaitForUpdate[consentKey] - ?.default || googleConsentTypes.DENIED, - }; - }, initialValue); - - // Write default consent to data layer - this.write('consent', 'default', consentRegistry); - - // write regions to data layer if they exists - const regions = this.config.regions; - if (regions) { - regions.forEach(region => { - this.write('consent', 'default', region); - }); + this.write(consentCommand, command, consent); + }); + } + } catch { + // Do nothing... } + } + } + + /** + * Loads default values from the configuration and + * writes them in a cookie for sharing. + * + * @param {object} initConsent - The consent data available, which can be null if the user has not yet given consent. + */ + loadDefaultsFromConfig(initConsent) { + const initialValue = {}; - // after write default consents, then write first update with initial consent data - this.updateConsent(initConsent); + if (this.waitForUpdate) { + initialValue['wait_for_update'] = this.waitForUpdate; } + + // Obtain default google consent registry + const consentRegistry = Object.keys(this.configWithConsentOnly).reduce( + (result, consentKey) => ({ + ...result, + [consentKey]: + this.configWithConsentOnly[consentKey]?.default || + googleConsentTypes.DENIED, + }), + initialValue, + ); + + // Write default consent to data layer + this.write('consent', 'default', consentRegistry); + + // write regions to data layer if they exist + const regions = this.regions; + + if (regions) { + regions.forEach(region => { + this.write('consent', 'default', region); + }); + } + + this.updateConsent(initConsent); + + this.saveConsent(); } /** - * Update consent. + * Try to set consent types with dataLayer. If a valid + * config was passed, default values for the consent + * types are used. Else, try to load the commands + * set from the cookie if it is available. * - * @param {object} consentData - The consent data to be set. + * @param initConsent - The consent data available, which can be null if the user has not yet given consent. */ - updateConsent(consentData) { - if (this.config) { - // Dealing with null or undefined consent values - const safeConsent = consentData || {}; + initialize(initConsent) { + if (this.hasConfig) { + this.loadDefaultsFromConfig(initConsent); + } else { + this.loadSharedConsentFromCookies(); + } + } + /** + * Writes consent updates to the dataLayer + * by applying the configuration (if any) to + * the passed consent data. + * + * @param {object} consentData - Consent data obtained from the user or null if not available. + */ + updateConsent(consentData) { + if (this.hasConfig && consentData) { // Fill consent value into consent element, using analytics consent categories - const consentRegistry = Object.keys( - this.configExcludingModeRegionsAndWaitForUpdate, - ).reduce((result, consentKey) => { - let consentValue = googleConsentTypes.DENIED; - const consent = - this.configExcludingModeRegionsAndWaitForUpdate[consentKey]; - - if (consent) { - // has consent config key - - if (consent.getConsentValue) { - // give priority to custom function - consentValue = consent.getConsentValue(safeConsent); - } else if ( - consent?.categories !== undefined && - consent.categories.every(consent => safeConsent[consent]) - ) { - // The second option to assign value is by categories list - consentValue = googleConsentTypes.GRANTED; + const consentRegistry = Object.keys(this.configWithConsentOnly).reduce( + (result, consentKey) => { + let consentValue = googleConsentTypes.DENIED; + const consent = this.configWithConsentOnly[consentKey]; + + if (consent) { + // has consent config key + if (consent.getConsentValue) { + // give priority to custom function + consentValue = consent.getConsentValue(consentData); + } else if ( + consent?.categories !== undefined && + consent.categories.every(consent => consentData[consent]) + ) { + // The second option to assign value is by categories list + consentValue = googleConsentTypes.GRANTED; + } } - } - return { - ...result, - [consentKey]: consentValue, - }; - }, {}); + return { + ...result, + [consentKey]: consentValue, + }; + }, + {}, + ); // Write consent to data layer this.write('consent', 'update', consentRegistry); + + this.saveConsent(); } } /** - * Write consent on data layer. + * Saves calculated google consent mode to a cookie + * for sharing consent between apps in same + * domain. + */ + saveConsent() { + if (this.consentDataLayerCommands.length > 0) { + setCookie( + GCM_SHARED_COOKIE_NAME, + JSON.stringify(this.consentDataLayerCommands), + ); + } + } + + /** + * Handles consent by updating the data layer with consent information. * - * @param {string} consentCommand - The consent command "consent". + * @param {string} consent - The consent command "consent". + * @param consentCommand * @param {string} command - The command "default" or "update". * @param {object} consentParams - The consent arguments. */ - // eslint-disable-next-line no-unused-vars write(consentCommand, command, consentParams) { // Without using the arguments reference, google debug mode would not seem to register the consent // that was written to the datalayer, so the parameters added to the function signature are only to // avoid mistakes when calling the function. - if ( - this.config && - typeof window !== 'undefined' && - consentParams && - !isEqual(this.lastConsent, consentParams) - ) { - // @ts-ignore + if (typeof window !== 'undefined' && consentParams) { window[this.dataLayer] = window[this.dataLayer] || []; + // eslint-disable-next-line prefer-rest-params window[this.dataLayer].push(arguments); - this.lastConsent = consentParams; + + this.consentDataLayerCommands.push([ + consentCommand, + command, + consentParams, + ]); } } } diff --git a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/GoogleConsentMode.test.js b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/GoogleConsentMode.test.js index 2839e19e6..d53493001 100644 --- a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/GoogleConsentMode.test.js +++ b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/GoogleConsentMode.test.js @@ -1,4 +1,16 @@ -import { GoogleConsentMode, googleConsentTypes } from '../index.js'; +import { GoogleConsentMode, googleConsentTypes } from '..'; + +function deleteAllCookies() { + const cookies = document.cookie.split(';'); + + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i]; + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; + } +} describe('GoogleConsentMode', () => { const dataLayerName = 'dataLayer'; @@ -29,6 +41,8 @@ describe('GoogleConsentMode', () => { beforeEach(() => { window[dataLayerName] = []; + + deleteAllCookies(); }); describe('Basic Google Consent Mode Configuration', () => { @@ -41,8 +55,8 @@ describe('GoogleConsentMode', () => { expect(googleConsent).toBeInstanceOf(GoogleConsentMode); - expect(window.dataLayer).toHaveLength(2); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(2); + expect(window[dataLayerName]).toMatchSnapshot(); }); it('should update dataLayer consent when "updateConsent" is called', () => { @@ -52,34 +66,22 @@ describe('GoogleConsentMode', () => { defaultConsentConfig, ); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(2); + + jest.clearAllMocks(); + + window[dataLayerName] = []; googleConsent.updateConsent(mockConsent); - expect(window.dataLayer).toMatchSnapshot(); - expect(window.dataLayer).toHaveLength(2); + expect(window[dataLayerName]).toMatchSnapshot(); }); it('should not write to datalayer if no configuration set', () => { new GoogleConsentMode(dataLayerName, mockConsent); - expect(window.dataLayer).toEqual([]); - }); - - it('should not update twice dataLayer consent when "updateConsent" is called with same values', () => { - const googleConsent = new GoogleConsentMode( - dataLayerName, - mockConsent, - defaultConsentConfig, - ); - - expect(googleConsent).toBeInstanceOf(GoogleConsentMode); - - expect(window.dataLayer).toHaveLength(2); - expect(window.dataLayer).toMatchSnapshot(); - - googleConsent.updateConsent(mockConsent); - expect(window.dataLayer).toHaveLength(2); + expect(window[dataLayerName]).toEqual([]); }); }); @@ -111,7 +113,7 @@ describe('GoogleConsentMode', () => { // update consent with grant conditions googleConsent.updateConsent({ ...mockConsent, consent_x: true }); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toMatchSnapshot(); }); it('should deal with no categories or getConsentValue function set', () => { @@ -126,7 +128,7 @@ describe('GoogleConsentMode', () => { googleConsent.updateConsent(mockConsent); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toMatchSnapshot(); }); }); @@ -150,8 +152,8 @@ describe('GoogleConsentMode', () => { expect(googleConsent).toBeInstanceOf(GoogleConsentMode); - expect(window.dataLayer).toHaveLength(3); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(3); + expect(window[dataLayerName]).toMatchSnapshot(); }); it('should update dataLayer consent when "updateConsent" is called', () => { @@ -161,12 +163,12 @@ describe('GoogleConsentMode', () => { defaultRegionConsentConfig, ); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toMatchSnapshot(); googleConsent.updateConsent(mockConsent); - expect(window.dataLayer).toMatchSnapshot(); - expect(window.dataLayer).toHaveLength(3); + expect(window[dataLayerName]).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(4); }); it('should update dataLayer consent when "updateConsent" is called with `wait_for_update` property', () => { @@ -179,13 +181,99 @@ describe('GoogleConsentMode', () => { }, ); - expect(window.dataLayer).toMatchSnapshot(); - expect(window.dataLayer).toHaveLength(3); + expect(window[dataLayerName]).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(3); googleConsent.updateConsent(mockConsent); - expect(window.dataLayer).toHaveLength(4); - expect(window.dataLayer).toMatchSnapshot(); + expect(window[dataLayerName]).toHaveLength(4); + expect(window[dataLayerName]).toMatchSnapshot(); + }); + }); + + describe('sharing cookie with consent', () => { + describe('when consent configuration is provided', () => { + it('should save a cookie when consent is written to dataLayer', () => { + const googleConsent = new GoogleConsentMode( + dataLayerName, + null, + defaultConsentConfig, + ); + + expect(document.cookie).toMatchSnapshot(); + + googleConsent.updateConsent(mockConsent); + + expect(document.cookie).toMatchSnapshot(); + }); + }); + + describe('when configuration is not provided', () => { + it('should load and write consent from the cookie if available', () => { + // Write cookie with consent values + new GoogleConsentMode( + dataLayerName, + mockConsent, + defaultConsentConfig, + ); + + expect(document.cookie).not.toBe(''); + + window[dataLayerName] = []; + + // Create new instance without configuration to test + new GoogleConsentMode(dataLayerName, null); + + expect(window[dataLayerName]).toMatchSnapshot(); + }); + + it('should not write consent if cookie is not available', () => { + // Create new instance without configuration to test + new GoogleConsentMode(dataLayerName, mockConsent); + + expect(window[dataLayerName]).toEqual([]); + }); + }); + + describe('when partial configuration is provided which does not include consent configuration', () => { + it('should write consent to dataLayer when cookie is available', () => { + // Write cookie with consent values + new GoogleConsentMode( + dataLayerName, + mockConsent, + defaultConsentConfig, + ); + + expect(document.cookie).not.toBe(''); + + window[dataLayerName] = []; + + // @ts-expect-error Force partial configuration + new GoogleConsentMode(dataLayerName, mockConsent, { + mode: 'Advanced', + waitForUpdate: 100, + }); + + expect(window[dataLayerName]).toMatchSnapshot(); + }); + + it('should not write consent to dataLayer when cookie is not available', () => { + const googleConsent = new GoogleConsentMode( + dataLayerName, + mockConsent, + // @ts-expect-error Force partial configuration + { + mode: 'Advanced', + waitForUpdate: 100, + }, + ); + + expect(window[dataLayerName]).toEqual([]); + + googleConsent.updateConsent(mockConsent); + + expect(window[dataLayerName]).toEqual([]); + }); }); }); }); diff --git a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/__snapshots__/GoogleConsentMode.test.js.snap b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/__snapshots__/GoogleConsentMode.test.js.snap index 0a07ec818..88720e926 100644 --- a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/__snapshots__/GoogleConsentMode.test.js.snap +++ b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/__tests__/__snapshots__/GoogleConsentMode.test.js.snap @@ -25,7 +25,7 @@ Array [ ] `; -exports[`GoogleConsentMode Basic Google Consent Mode Configuration should not update twice dataLayer consent when "updateConsent" is called with same values 1`] = ` +exports[`GoogleConsentMode Basic Google Consent Mode Configuration should update dataLayer consent when "updateConsent" is called 1`] = ` Array [ Arguments [ "consent", @@ -50,7 +50,22 @@ Array [ ] `; -exports[`GoogleConsentMode Basic Google Consent Mode Configuration should update dataLayer consent when "updateConsent" is called 1`] = ` +exports[`GoogleConsentMode Basic Google Consent Mode Configuration should update dataLayer consent when "updateConsent" is called 2`] = ` +Array [ + Arguments [ + "consent", + "update", + Object { + "ad_personalization": "granted", + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], +] +`; + +exports[`GoogleConsentMode Extended Google Consent Mode Configuration sharing cookie with consent when configuration is not provided should load and write consent from the cookie if available 1`] = ` Array [ Arguments [ "consent", @@ -75,7 +90,11 @@ Array [ ] `; -exports[`GoogleConsentMode Basic Google Consent Mode Configuration should update dataLayer consent when "updateConsent" is called 2`] = ` +exports[`GoogleConsentMode Extended Google Consent Mode Configuration sharing cookie with consent when consent configuration is provided should save a cookie when consent is written to dataLayer 1`] = `"@farfetch/blackout-react__gcm_shared_consent_mode=[[\\"consent\\",\\"default\\",{\\"ad_storage\\":\\"denied\\",\\"ad_user_data\\":\\"denied\\",\\"ad_personalization\\":\\"denied\\",\\"analytics_storage\\":\\"denied\\"}]]"`; + +exports[`GoogleConsentMode Extended Google Consent Mode Configuration sharing cookie with consent when consent configuration is provided should save a cookie when consent is written to dataLayer 2`] = `"@farfetch/blackout-react__gcm_shared_consent_mode=[[\\"consent\\",\\"default\\",{\\"ad_storage\\":\\"denied\\",\\"ad_user_data\\":\\"denied\\",\\"ad_personalization\\":\\"denied\\",\\"analytics_storage\\":\\"denied\\"}],[\\"consent\\",\\"update\\",{\\"ad_storage\\":\\"granted\\",\\"ad_user_data\\":\\"granted\\",\\"ad_personalization\\":\\"granted\\",\\"analytics_storage\\":\\"granted\\"}]]"`; + +exports[`GoogleConsentMode Extended Google Consent Mode Configuration sharing cookie with consent when partial configuration is provided which does not include consent configuration should write consent to dataLayer when cookie is available 1`] = ` Array [ Arguments [ "consent", @@ -205,6 +224,16 @@ Array [ "analytics_storage": "granted", }, ], + Arguments [ + "consent", + "update", + Object { + "ad_personalization": "granted", + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], ] `; @@ -314,6 +343,36 @@ Array [ "analytics_storage": "granted", }, ], + Arguments [ + "consent", + "update", + Object { + "ad_personalization": undefined, + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], + Arguments [ + "consent", + "update", + Object { + "ad_personalization": undefined, + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], + Arguments [ + "consent", + "update", + Object { + "ad_personalization": undefined, + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], ] `; @@ -339,5 +398,15 @@ Array [ "analytics_storage": "granted", }, ], + Arguments [ + "consent", + "update", + Object { + "ad_personalization": "denied", + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], ] `; diff --git a/packages/react/src/analytics/integrations/shared/GoogleConsentMode/cookieUtils.js b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/cookieUtils.js new file mode 100644 index 000000000..b56fedf98 --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/GoogleConsentMode/cookieUtils.js @@ -0,0 +1,31 @@ +import { PACKAGE_NAME } from '../../../constants'; + +export const GCM_SHARED_COOKIE_NAME = `${PACKAGE_NAME}__gcm_shared_consent_mode`; + +/** + * + */ +function getDomain() { + const fullDomain = window.location.hostname; + const domainParts = fullDomain.split('.'); + + // Check if there is a subdomain + if (domainParts.length > 1) { + return ( + domainParts[domainParts.length - 2] + + '.' + + domainParts[domainParts.length - 1] + ); + } + + return fullDomain; +} + +/** + * @param name - Cookie name. + * @param value - Cookie value. + * @param domain - Domain to set. If not passed, will use the parent domain from the hostname. + */ +export function setCookie(name, value, domain = getDomain()) { + document.cookie = name + '=' + (value || '') + '; path=/ ; domain=' + domain; +}