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

Add Google consent mode support #266

Merged
merged 3 commits into from
Sep 26, 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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
16
15 changes: 14 additions & 1 deletion packages/cookie-banner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ The consent form renders into a DOMElement with a particular className configura

A page containing a cookie consent form should include a visually hidden live region (role=alert) with a particular className (classNames.formAnnouncement), default: 'privacy-banner__form-announcement'.

Optionally the banner also supports basic Google EU consent mode [https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic], and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to suport Google consent mode is to map Google consent categories to the cookie categories in the configuration.

For example, to map the ad_storage, ad_user_data, and ad_personalisation to an 'ads' consent category defined in the banner config, add a `euConsentTypes` object to the configuration like this:

```
euConsentTypes: {
ad_storage: 'test',
ad_user_data: 'test',
ad_personalization: 'test'
}
```


Install the package
```
Expand Down Expand Up @@ -80,7 +92,8 @@ const cookieBanner = banner({
secure: true, //preferences cookie secure
samesite: 'lax', //preferences cookie samesite
expiry: 365, //preferences cookie expiry in days
types: {}, //types of cookie-dependent functionality
types: {}, //types of cookie-dependent functionality
euConsentTypes: {}, //map Google EU consent categories to types of cookie defined in 'types'
necessary: [], //cookie-dependent functionality that will always execute, for convenience only
policyURL: '/cookie-policy#preferences', //URL to cookie policy page (location of cookie consent form) rendered in the banner
classNames: {
Expand Down
65 changes: 65 additions & 0 deletions packages/cookie-banner/__tests__/google-eu-consent/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import cookieBanner from '../../src';
import defaults from '../../src/lib/defaults';

/* eslint-disable camelcase */
const init = () => {
// Set up our document body
document.body.innerHTML = `<main></main>`;
window.__cb__ = cookieBanner({
secure: false,
euConsentTypes: {
ad_storage: 'test',
ad_user_data: 'test',
ad_personalization: 'test',
analytics_storage: 'performance'
},
types: {
test: {
title: 'Test title',
description: 'Test description',
labels: {
yes: 'Pages you visit and actions you take will be measured and used to improve the service',
no: 'Pages you visit and actions you take will not be measured and used to improve the service'
},
fns: [
() => { }
]
},
performance: {
title: 'Performance preferences',
description: 'Performance cookies are used to measure the performance of our website and make improvements. Your personal data is not identified.',
labels: {
yes: 'Pages you visit and actions you take will be measured and used to improve the service',
no: 'Pages you visit and actions you take will not be measured and used to improve the service'
},
fns: [
() => { }
]
}
}
});
};


describe(`Cookie banner > cookies > Google EU consent > default event`, () => {
beforeAll(init);

it('must set a default consent event with all categories denied', async () => {
const banner = document.querySelector(`.${defaults.classNames.banner}`);
expect(banner).not.toBeNull();

//These assertions break Jest because of the use 'arguments' in the gtag implementation
//They have been manually validated in the browser
// expect(window.dataLayer).toEqual([
// ['consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied' }]
// ]);
// const acceptAllBtn = document.querySelector(`.${defaults.classNames.acceptBtn}`);
// acceptAllBtn.click();

// expect(window.dataLayer).toEqual([
// ['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' }]
// ]);
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import cookieBanner from '../../src';
import defaults from '../../src/lib/defaults';

const init = () => {
// Set up our document body
document.body.innerHTML = `<main></main>`;
window.__cb__ = cookieBanner({
secure: false,
types: {
test: {
title: 'Test title',
description: 'Test description',
labels: {
yes: 'Pages you visit and actions you take will be measured and used to improve the service',
no: 'Pages you visit and actions you take will not be measured and used to improve the service'
},
fns: [
() => { }
]
},
performance: {
title: 'Performance preferences',
description: 'Performance cookies are used to measure the performance of our website and make improvements. Your personal data is not identified.',
labels: {
yes: 'Pages you visit and actions you take will be measured and used to improve the service',
no: 'Pages you visit and actions you take will not be measured and used to improve the service'
},
fns: [
() => { }
]
}
}
});
};


describe(`Cookie banner > cookies > Google EU consent > no EU consent settings`, () => {
beforeAll(init);

it('No errors or pushes to dataLayer if no consent options configured', async () => {
const banner = document.querySelector(`.${defaults.classNames.banner}`);
expect(banner).not.toBeNull();
expect(window.dataLayer).toBeUndefined();
});

});
6 changes: 6 additions & 0 deletions packages/cookie-banner/example/src/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const writeCookie = state => {
const config = {
name: '.Components.Dev.Consent',
secure: false,
euConsentTypes: {
ad_storage: 'ads',
ad_user_data: 'ads',
ad_personalization: 'ads',
analytics_storage: 'performance'
},
hideBannerOnFormPage: false,
trapTab: true,
necessary: [
Expand Down
6 changes: 4 additions & 2 deletions packages/cookie-banner/src/lib/factory.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cookiesEnabled, extractFromCookie, noop, renderIframe, gtmSnippet } from './utils';
import { cookiesEnabled, extractFromCookie, noop, renderIframe, gtmSnippet, setGoogleConsent } from './utils';
import { showBanner, initBanner, initForm, initBannerListeners, keyListener } from './ui';
import { necessary, apply } from './consent';
import { createStore } from './store';
Expand All @@ -25,10 +25,12 @@ export default settings => {
},
[
necessary,
setGoogleConsent(Store, 'default'),
apply(Store),
hasCookie ? noop : initBanner(Store),
initForm(Store),
initBannerListeners(Store)
initBannerListeners(Store),
hasCookie ? setGoogleConsent(Store) : noop
]
);

Expand Down
11 changes: 7 additions & 4 deletions packages/cookie-banner/src/lib/ui.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { writeCookie, groupValueReducer, deleteCookies, getFocusableChildren, broadcast } from './utils';
import { writeCookie, groupValueReducer, deleteCookies, getFocusableChildren, broadcast, setGoogleConsent } from './utils';
import { ACCEPTED_TRIGGERS, EVENTS } from './constants';
import { apply } from './consent';
import { updateConsent, updateBannerOpen, updateBanner } from './reducers';
Expand Down Expand Up @@ -47,7 +47,8 @@ export const initBannerListeners = Store => () => {
apply(Store),
removeBanner(Store),
initForm(Store, false),
broadcast(EVENTS.CONSENT, Store)
broadcast(EVENTS.CONSENT, Store),
setGoogleConsent(Store),
]
);
});
Expand All @@ -65,7 +66,8 @@ export const initBannerListeners = Store => () => {
writeCookie,
removeBanner(Store),
initForm(Store, false),
broadcast(EVENTS.CONSENT, Store)
broadcast(EVENTS.CONSENT, Store),
setGoogleConsent(Store),
]
);
});
Expand Down Expand Up @@ -153,7 +155,8 @@ export const initForm = (Store, track = true) => () => {
removeBanner(Store),
broadcast(EVENTS.CONSENT, Store),
renderMessage(button),
renderAnnouncement(formAnnouncement)
renderAnnouncement(formAnnouncement),
setGoogleConsent(Store),
]
);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/cookie-banner/src/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,28 @@ export const gtmSnippet = id => {
s.async = !0, s.src = 'https://www.googletagmanager.com/gtm.js?id=' + w, r.parentNode.insertBefore(s, r)
}(window, document, "script", "dataLayer", id);
};

/* eslint-disable prefer-rest-params */
function gtag() {
window.dataLayer = window.dataLayer || [];
//The Google libraries that use the dataLayer do not work if arguments are spread
//or data is passed in as an array
window.dataLayer.push(arguments);
}

export const setGoogleConsent = (Store, pushType = 'update') => () => {
const { settings, consent } = Store.getState();
const { euConsentTypes } = settings;
if (!euConsentTypes) return;

const euConsent = Object.keys(euConsentTypes).reduce((acc, type) => {
if (Object.keys(consent).length > 0 && consent[euConsentTypes[type]] === undefined) {
console.warn(`Cannot find consent type '${euConsentTypes[type]}' in preferences cookie, check your euConsentTypes configuration matches your cookie types`);
}
acc[type] = (consent[euConsentTypes[type]] && pushType === 'update') ? 'granted' : 'denied';
Copy link
Contributor

@sarah-storm sarah-storm Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it'd be worth adding some sort of warning/error in here if a matching cookie type isn't found? Just in case a typo wipes out analytics consent and goes unnoticed.. is it safe to assume that there always should be a matching cookie if the euConsentTypes object has been added to the config?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout. If there are keys in the consent object and euConsentTypes configured then it should be able to find a match. Will update now.

return acc;
}, {});
if (pushType !== 'update') euConsent['wait_for_update'] = 500;

gtag('consent', pushType, euConsent);
};
Loading