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: add support for sharing gcm v1 #993

Merged
merged 1 commit into from
Mar 7, 2024
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
4 changes: 4 additions & 0 deletions packages/core/src/analytics/utils/getters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}=`);

Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/analytics/analytics.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/analytics/constants.js
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;
export const PACKAGE_NAME = name;
export const PACKAGE_NAME_VERSION = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
Original file line number Diff line number Diff line change
@@ -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,
]);
}
}
}
Expand Down
Loading
Loading