Skip to content

Commit

Permalink
feat: add support for sharing gcm
Browse files Browse the repository at this point in the history
This adds support for sharing google consent
mode with other apps such as Luxury Checkout
through a cookie set for the same domain.
  • Loading branch information
Bruno Oliveira authored and boliveira committed Mar 7, 2024
1 parent 8d70cbb commit 2cd74da
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 123 deletions.
4 changes: 4 additions & 0 deletions packages/analytics/src/utils/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export const getLocation = (
* @returns The cookie value.
*/
export const getCookie = (name: string): string | void => {
if (typeof document === 'undefined') {
return;
}

const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);

Expand Down
9 changes: 2 additions & 7 deletions packages/react/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { get } from 'lodash-es';
import { PACKAGE_NAME, PACKAGE_NAME_VERSION } from './constants.js';
import Analytics, {
type EventContextData,
type EventData,
Expand All @@ -11,12 +12,6 @@ import Analytics, {
import webContext, { type WebContext } from './context.js';
import WebContextStateManager from './WebContextStateManager.js';

const {
name: PACKAGE_NAME,
version: PACKAGE_VERSION,
// eslint-disable-next-line @typescript-eslint/no-var-requires
} = require('../../package.json');

/**
* Analytics facade for web applications. Refer to \@farfetch/blackout-analytics
* documentation to know the inherited methods from Analytics.
Expand Down Expand Up @@ -85,7 +80,7 @@ class AnalyticsWeb extends Analytics {
if (context) {
context.library = {
name: PACKAGE_NAME,
version: `${context.library.name}@${context.library.version};${PACKAGE_NAME}@${PACKAGE_VERSION};`,
version: `${context.library.name}@${context.library.version};${PACKAGE_NAME_VERSION};`,
};

const webContextStateSnapshot = this.webContextStateManager.getSnapshot();
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/analytics/constants.ts
Original file line number Diff line number Diff line change
@@ -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 as string;
export const PACKAGE_NAME = name as string;
export const PACKAGE_NAME_VERSION = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
Original file line number Diff line number Diff line change
@@ -1,122 +1,189 @@
import { type ConsentData } from '@farfetch/blackout-analytics';
import { type ConsentData, utils } from '@farfetch/blackout-analytics';
import { GCM_SHARED_COOKIE_NAME, setCookie } from './cookieUtils.js';
import {
type GoogleConsentCategoryConfig,
type GoogleConsentModeConfig,
GoogleConsentType,
} from './types.js';
import { isEqual, omit } from 'lodash-es';
import { omit } from 'lodash-es';

/**
* GoogleConsentMode handles with Google Consent Mode v2.
*/
export class GoogleConsentMode {
private dataLayer!: string; // Stores different data layer names
private config?: GoogleConsentModeConfig; // Stores default or customized consent category mappings
private configExcludingModeRegionsAndWaitForUpdate!: Record<
string,
GoogleConsentCategoryConfig
>; // exclude not consent properties from config
private lastConsent?: Record<
string,
Array<string> | string | number | undefined
>;
private configWithConsentOnly!: Record<string, GoogleConsentCategoryConfig>; // exclude not consent properties from config
private consentDataLayerCommands: Array<
[
'consent',
'default' | 'update',
Record<string, Array<string> | string | number | undefined> | undefined,
]
> = [];
private waitForUpdate?: number;
private regions?: GoogleConsentModeConfig['regions'];
private hasConfig: boolean;

constructor(
dataLayer: string,
initConsent: ConsentData | null,
config?: GoogleConsentModeConfig,
) {
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);
}

/**
* Write google consent default values to dataLayer.
*
* @param initConsent - The init consent data to be set.
* 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.
*/
private loadDefaults(initConsent: ConsentData | null) {
if (this.config) {
const initialValue: Record<string, string | number> = {};
private 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;

this.write(consentCommand, command, consent);
});
}
} catch {
// Do nothing...
}
}
}

// Obtain default google consent registry
const consentRegistry = Object.keys(
this.configExcludingModeRegionsAndWaitForUpdate,
).reduce(
(result, consentKey) => ({
...result,
[consentKey]:
this.configExcludingModeRegionsAndWaitForUpdate[consentKey]
?.default || GoogleConsentType.Denied,
}),
initialValue,
);
/**
* Loads default values from the configuration and
* writes them in a cookie for sharing.
*
* @param initConsent - The consent data available, which can be null if the user has not yet given consent.
*/
private loadDefaultsFromConfig(initConsent: ConsentData | null) {
const initialValue: Record<string, string | number> = {};

// Write default consent to data layer
this.write('consent', 'default', consentRegistry);
if (this.waitForUpdate) {
initialValue['wait_for_update'] = this.waitForUpdate;
}

// write regions to data layer if they exists
this.config.regions?.forEach(region => {
// Obtain default google consent registry
const consentRegistry = Object.keys(this.configWithConsentOnly).reduce(
(result, consentKey) => ({
...result,
[consentKey]:
this.configWithConsentOnly[consentKey]?.default ||
GoogleConsentType.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.updateConsent(initConsent);
this.saveConsent();
}

/**
* 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 initConsent - The consent data available, which can be null if the user has not yet given consent.
*/
private initialize(initConsent: ConsentData | null) {
if (this.hasConfig) {
this.loadDefaultsFromConfig(initConsent);
} else {
this.loadSharedConsentFromCookies();
}
}

/**
* Update consent.
* Writes consent updates to the dataLayer
* by applying the configuration (if any) to
* the passed consent data.
*
* @param consentData - The consent data to be set.
* @param consentData - Consent data obtained from the user or null if not available.
*/
updateConsent(consentData: ConsentData | null) {
if (this.config) {
// Dealing with null or undefined consent values
const safeConsent = 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 = GoogleConsentType.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 = GoogleConsentType.Granted;
const consentRegistry = Object.keys(this.configWithConsentOnly).reduce(
(result, consentKey) => {
let consentValue = GoogleConsentType.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 = GoogleConsentType.Granted;
}
}
}

return {
...result,
[consentKey]: consentValue,
};
}, {});
return {
...result,
[consentKey]: consentValue,
};
},
{},
);

// Write consent to data layer
this.write('consent', 'update', consentRegistry);

this.saveConsent();
}
}

/**
* 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),
);
}
}

Expand All @@ -128,11 +195,8 @@ export class GoogleConsentMode {
* @param consentParams - The consent arguments.
*/
private write(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
consentCommand: 'consent',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command: 'default' | 'update',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
consentParams:
| Record<string, Array<string> | string | number | undefined>
| undefined,
Expand All @@ -141,19 +205,19 @@ export class GoogleConsentMode {
// 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)
) {
if (typeof window !== 'undefined' && consentParams) {
// @ts-ignore
window[this.dataLayer] = window[this.dataLayer] || [];

// @ts-ignore
// eslint-disable-next-line prefer-rest-params
window[this.dataLayer].push(arguments);
this.lastConsent = consentParams;

this.consentDataLayerCommands.push([
consentCommand,
command,
consentParams,
]);
}
}
}
Expand Down
Loading

0 comments on commit 2cd74da

Please sign in to comment.