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

Minimal non-intrusive Cookie consent block #50

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ef29fed
feat(cookieconsent): cookie consent block
chicharr Feb 8, 2024
8a0387b
fix: button styles
chicharr Feb 8, 2024
2908fd9
fix: linting
chicharr Feb 8, 2024
ca765b1
chore: enable consent dialog to update preferences
chicharr Feb 9, 2024
7ebd360
chore: refactor local storage
chicharr Feb 9, 2024
a33f909
added call to cookie dialog from footer
cuzuco2 Feb 9, 2024
a8561ec
Merge pull request #52 from cuzuco2/cookieconsent
chicharr Feb 9, 2024
775d943
cookie consent simplify and clean up the code
chicharr Feb 16, 2024
907513c
chore: linting
chicharr Feb 16, 2024
4c49f7c
chore: footer link
chicharr Feb 16, 2024
cf32bc3
chore(rum): track showConsent checkpoint
chicharr Feb 16, 2024
76941b8
fix: rum source
chicharr Feb 16, 2024
f21b989
chore: project refresh
kptdobe Feb 19, 2024
66974be
Merge branch 'dep-update' into cookieconsent
kptdobe Feb 19, 2024
ab545b5
chore: fix linting
kptdobe Feb 19, 2024
e3e16f5
Merge branch 'main' into cookieconsent
kptdobe Feb 19, 2024
2b2a104
feat: new banner design
kptdobe Feb 19, 2024
8f1eb2a
Merge branch 'main' into cookieconsent-css
kptdobe Feb 22, 2024
90bfaa3
Merge branch 'main' into cookieconsent-css
kptdobe Feb 22, 2024
3199f3e
Merge branch 'main' into cookieconsent
kptdobe Feb 22, 2024
b5bb05c
Merge pull request #54 from adobe/cookieconsent-css
chicharr Mar 4, 2024
ee5c86c
chore: quick link color change
davidnuescheler Mar 4, 2024
5ef07e2
chore: refactor
chicharr Mar 12, 2024
33db56f
chore(readme): update readme
chicharr Mar 13, 2024
0b22b46
Update README.md
chicharr Mar 13, 2024
553de90
chore: linting issues
chicharr Mar 13, 2024
de7f903
chore: integrate feedback (#58)
kptdobe Mar 22, 2024
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
79 changes: 79 additions & 0 deletions blocks/consent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Consent Block
Consent block provides a minimalistic, performant and non-intrusive cookie consent banner.

## The basics

* This block displays a minimalistic non-intrusive cookie consent banner
* Pages where cookie consent banner needs to be shown, must have a metadata property called `cookie-consent`, specifying the name of the cookie consent banner we want to show.
* The content of the cookie consent banner is in a separate document normally in the path `/cookie-consent/<name-of-the-consent-banner>`
* When a user selects their consent preferences, they are stored in the local storage.
* The banner is only shown on page load if a user doesn't have preferences in the local storage.
* When consent preferences are updated or read from the browser, a custom event is triggered. Martech loaders can listen to this events to decide what to load or not load, based on user preferences.

## Cookie Consent Flow
![Cookie Consent flow](https://github.com/adobe/aem-block-collection/assets/43381734/53583ee0-da46-4f1a-91f4-39411305bf47)


## Content of the banner
We offer 2 possibilities:

### 1. Simple consent banner
One simple text explaining that the website is using cookies. Buttons `accept all`, `deny all` can be added optionally.

*Word document*:
Contains a single section with the text, which can be styled, and metadata.

Metadata properties:
* `required cookies`: List of cookie category codes that will be always active, regardless of what the user selects.
* `optional categories`: List of cookie category codes that are optional, that will only be active if the user explicitly consents, by clicking `accept all` button.
* `buttons`: comma-separated list. accepted values: `accept-all` , `deny-all`

*Samples*:
* configuration:

![Consent configuration](https://github.com/adobe/aem-block-collection/assets/43381734/e8e52be7-1cf5-4820-8384-76ff228be061)

* banner displayed:

![Consent banner preview](https://github.com/adobe/aem-block-collection/assets/43381734/7b70dfe8-1d79-432e-8e74-f09af016bab7)



### 2. Simple consent banner and categories details
Shows the simple consent banner as the previous one, but also offers a dialog which displays detailed information of each of the categories of cookies the website uses, and allows users to select individually each category.
By default only the minimalistic consent banner is shown, but users can click a "more information" button that will display the dialog.

*Word document*:
* First section has the content of the simple content banner.
In this case the only allowed metadata property is:
`buttons` = `accept_all | reject_all | more_info`

* The second and subsequent sections are only used in the detailed categories dialog.
It expects a first intro section with some explanation text that is displayed on top of the dialog.
The second and subsequent sections are considered to be cookie categories. And each of them need 2 metadata properties:
`code` = code of the category that will be used by martech loaders
`optional` = whether the category is optional or not.

*Samples*
* configuration

![Multiple consent configuration samples](https://github.com/adobe/aem-block-collection/assets/43381734/1fba9fcf-19a8-4f0d-9e3d-741e77befefb)


* dialog displayed

![Consent dialog preview](https://github.com/adobe/aem-block-collection/assets/43381734/72929596-0b25-450a-9332-72dea6d94204)

## Update consent
Sometimes users want to change their cookie preferences. For this purpose there is a function in the block `blocks/consent/consent.js` called `showConsentForUpdate(name)`.
Where `name` is the name of the consent configuration document. This function is expected to be called when a user clicks on the "cookie preferences" or similar link.

* In case of *#1. Simple consent banner* this function will show the minimalistic non-intrusive banner
* In case of *#2. Simple consent banner and categories details* this function would show the consent categories detail dialog.

## Block setup

Block needs to be loaded as quickly as possible and the logic to load the block or not highly depends on project needs. Here are the two elements you may want to patch in your project:

- in [scripts.js](../../scripts.js), you need to load the consent block in the lazy phase to load the consent banner
- in [footer](../header/footer.js), you can "patch" the Cookie Preferences link to open the consent dialog using the `setupConsentPreferenceLink` function from the block.
155 changes: 155 additions & 0 deletions blocks/consent/consent-banner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
fetchPlaceholders,
} from '../../scripts/aem.js';

import { loadFragment } from '../fragment/fragment.js';
import { buildAndShowDialog } from './consent-dialog.js';

const BASE_CONSENT_PATH = '/block-collection/cookie-consent';

function addListeners(bannerDiv, consentUpdateCallback, arrayCategories, categoriesSections) {
const acceptAll = bannerDiv.querySelector('.consent.banner .accept');
const rejectAll = bannerDiv.querySelector('.consent.banner .decline');
const moreInformation = bannerDiv.querySelector('.consent.banner .more-info');

if (acceptAll) {
acceptAll.addEventListener('click', () => {
consentUpdateCallback(arrayCategories.map((c) => c.code));
bannerDiv.remove();
});
}
if (rejectAll) {
rejectAll.addEventListener('click', () => {
consentUpdateCallback(arrayCategories.filter((c) => !c.optional)
.map((c) => c.code));
bannerDiv.remove();
});
}
if (moreInformation && categoriesSections) {
moreInformation.addEventListener('click', () => {
buildAndShowDialog(categoriesSections, consentUpdateCallback);
bannerDiv.remove();
});
}
}
/**
* Returns the categories from the banner.
* Categories can come from:
* the section metadata properties: 'required cookies' and 'optional cookies'
* or if categories sections are available from their metadata.
* @param {*} bannerSection the section where banner is defined
* @param {*} categoriesSections array of sections where categories are defined.
* @returns array of categories, where each entry has category code and the optional flag
*/
function getCategoriesInBanner(bannerSection, categoriesSections) {
// If banner section has metadata about the cookie categories
if (bannerSection.getAttribute('data-required-cookies') || bannerSection.getAttribute('data-optional-cookies')) {
const categories = [];
if (bannerSection.getAttribute('data-required-cookies')) {
bannerSection.getAttribute('data-required-cookies').split(',')
.map((c) => c.trim())
.forEach((c) => categories.push({ code: c, optional: false }));
}

if (bannerSection.getAttribute('data-optional-cookies')) {
bannerSection.getAttribute('data-optional-cookies').split(',')
.map((c) => c.trim())
.forEach((c) => categories.push({ code: c, optional: true }));
}
return categories;
}
// Banner section doesn't have metadata about cookie categories,
// but the document contains categories sections => extract categories metadata from the sections
if (categoriesSections && categoriesSections.length) {
return categoriesSections
.filter((category) => category.dataset && category.dataset.code && category.dataset.optional)
.map((category) => ({ code: category.dataset.code, optional: ['yes', 'true'].includes(category.dataset.optional.toLowerCase().trim()) }));
}
return [{ code: 'CC_ESSENTIAL', optional: false }];
}

/**
* Creates the consent banner HTML
* @param {*} bannerSection the section where banner is defined
* @returns HTMLElement of the consent banner div
*/
function createBanner(bannerSection) {
const content = bannerSection.childNodes;
const buttonString = bannerSection.getAttribute('data-buttons') || 'accept_all';
const buttonsArray = buttonString.toLowerCase().split(',').map((s) => s.trim());
const placeholders = fetchPlaceholders();
const div = document.createElement('div');
div.classList.add('consent', 'banner');
div.append(...content);
const acceptAllButton = `<button class="consent-button accept primary">${placeholders.consentAcceptAll || 'Accept All'}</button>`;
const rejectAllButton = `<button class="consent-button decline secondary">${placeholders.consentDeclineAll || 'Decline All'}</button>`;
const moreInfoLink = `&nbsp;<a class="more-info">${placeholders.moreInformation || 'More Information'}</a>`;
if (buttonsArray.includes('more_info')) {
div.querySelector('p').append(document.createRange().createContextualFragment(moreInfoLink));
}
const buttonsHTML = `${buttonsArray.includes('accept_all') ? acceptAllButton : ''}${buttonsArray.includes('deny_all') ? rejectAllButton : ''}`;
if (buttonsHTML) {
const buttonsDiv = document.createElement('div');
buttonsDiv.classList = 'controls';
buttonsDiv.innerHTML = buttonsHTML;
div.append(buttonsDiv);
}

return div;
}

function buildAndShowBanner(consentSections, callback) {
const bannerSection = consentSections.shift();
const bannerElement = createBanner(bannerSection);
const categoriesMap = getCategoriesInBanner(bannerSection, consentSections);
addListeners(bannerElement, callback, categoriesMap, consentSections);
document.querySelector('main').append(bannerElement);
}

/**
* Gets the sections in a consent banner passed fragment.
* @param {String} consentName name of the consent banner
* @returns Array of sections in the consent banner section
*/
async function getSectionsFromConsentFragment(consentName) {
const path = `${BASE_CONSENT_PATH}/${consentName}`;
const fragment = await loadFragment(path);
if (!fragment) {
// eslint-disable-next-line no-console
console.debug('could not find consent fragment in path ', path);
return [];
}
return [...fragment.querySelectorAll('div.section')];
}

/**
* Shows a non-intrusive consent banner
* @param {String} consentName name of the consent banner to show, a document
* with that name should exist in the /cookie-consent folder
* @param {Function} consentUpdateCallback callback to execute when consent is updated
*/
export async function showConsentBanner(consentName, consentUpdateCallback) {
const consentSections = await getSectionsFromConsentFragment(consentName);
buildAndShowBanner(consentSections, consentUpdateCallback);
}

/**
* Shows the consent for update.
* If the consent banner fragment passed as a parameter has detailed consent categories
* defined, shows the modal dialog with the categories. If not shows the non-intrusive banner.
* @param {String} consentName name of the consent banner fragment to show, a document
* with that name should exist in the /cookie-consent folder
* @param {Function} consentUpdateCallback callback to execute when consent is updated
*/
export async function showConsentBannerForUpdate(consentName, consentUpdateCallback) {
const consentSections = await getSectionsFromConsentFragment(consentName);
if (consentSections && (consentSections.length > 1)) {
// If there are more than one section means that the fragment
// has defined cookie category sections
// We skip the banner section, and go to the category sections
consentSections.shift();
buildAndShowDialog(consentSections, consentUpdateCallback);
} else {
buildAndShowBanner(consentSections, consentUpdateCallback);
}
}
150 changes: 150 additions & 0 deletions blocks/consent/consent-dialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
decorateIcons, fetchPlaceholders,
} from '../../scripts/aem.js';

/**
*
* @param {String} mode type of consent selected { ALL | NONE | SELECTED }
* @param {Element} dialogContainer
* @param {*} consentUpdateCallback
* @param {*} categoriesMap
*/
function consentUpdated(mode, dialogContainer, consentUpdateCallback) {
// category list is not passed as a parameter, we get it from the checkboxes
const selectedCategories = [...dialogContainer.querySelectorAll('input[type=checkbox][data-cc-code]')]
.filter((cat) => mode === 'ALL' || (mode === 'NONE' && cat.disabled) || (mode === 'SELECTED' && cat.checked))
.map((cat) => cat.value);

// invoke the consent update logic
consentUpdateCallback(selectedCategories);
// close the dialog
dialogContainer.remove();
}

/** FULL DIALOG functions */
function consentButtonsPanelHTML() {
const placeholders = fetchPlaceholders();
return document.createRange().createContextualFragment(`
<div class='consent-buttons'>
<button class="consent-button decline secondary">${placeholders.consentDeclineAll || 'Decline All'}</button>
<button class="consent-button accept primary">${placeholders.consentAcceptAll || 'Accept All'}</button>
</div>`);
}

function consentCategoriesButtonsPanelHTML() {
const placeholders = fetchPlaceholders();
return document.createRange().createContextualFragment(`
<div class='consent-buttons-preferences'>
<button class="consent-button only-selected primary">${placeholders.consentSavePrefernces || 'Save my preferences'}</button>
</div>`);
}

function categoryHeaderHTML(title, code, optional, selected) {
return `
<div>
<p>${title}</p>
</div>
<div class="consent-category-switch">
<label class="switch">
<input type="checkbox" data-cc-code="${code}" value="${code}"
${!optional || selected ? ' checked ' : ''}
${!optional ? 'disabled' : ''} tabindex=0 />
<span class="slider round"></span>
</label>
</div>`;
}

function addCloseButton(banner) {
const closeButton = document.createElement('button');
closeButton.classList.add('close-button');
closeButton.setAttribute('aria-label', 'Close');
closeButton.type = 'button';
closeButton.innerHTML = '<span class="icon icon-close"></span>';
closeButton.addEventListener('click', () => (banner.close ? banner.close() : banner.remove()));
banner.append(closeButton);
decorateIcons(closeButton);
}

function generateCategoriesPanel(consentSections, selectedCategories) {
const placeholders = fetchPlaceholders();
const ccCategoriesSection = document.createElement('div');
ccCategoriesSection.classList = 'consent-categories-panel';
const ccCategoriesDetails = document.createElement('div');
ccCategoriesDetails.classList = 'accordion';
consentSections.forEach((category) => {
const optional = ['yes', 'true'].includes(category.dataset.optional.toLowerCase().trim());
const title = category.querySelector('h2') || category.firstElementChild.firstElementChild;
const categoryHeader = document.createElement('div');
categoryHeader.classList = 'consent-category-header';
const selected = selectedCategories && selectedCategories.includes(category.dataset.code);
// eslint-disable-next-line max-len
categoryHeader.innerHTML = categoryHeaderHTML(title.innerHTML, category.dataset.code, optional, selected);

const summary = document.createElement('summary');
summary.className = 'accordion-item-label';
summary.append(categoryHeader);

// decorate accordion item body
const body = document.createElement('div');
body.className = 'accordion-item-body';
const bodyContent = [...category.firstElementChild.children].slice(1);
body.append(...bodyContent);

// decorate accordion item
const details = document.createElement('details');
details.className = 'accordion-item';
details.append(summary, body);
ccCategoriesDetails.append(details);
category.remove();
});

const ccCategoriesSectionTitle = document.createElement('div');
ccCategoriesSectionTitle.innerHTML = `<h2>${placeholders.consentCookieSettings || 'Cookie Settings'}</h2>`;
ccCategoriesSection.append(ccCategoriesSectionTitle);
ccCategoriesSection.append(ccCategoriesDetails);
ccCategoriesSection.append(consentCategoriesButtonsPanelHTML(placeholders));
return ccCategoriesSection;
}

function addListeners(dialogContainer, consentUpdateCallback) {
dialogContainer.querySelector('.consent-button.accept').addEventListener('click', () => consentUpdated('ALL', dialogContainer, consentUpdateCallback));
dialogContainer.querySelector('.consent-button.decline').addEventListener('click', () => consentUpdated('NONE', dialogContainer, consentUpdateCallback));
dialogContainer.querySelector('.consent-button.only-selected').addEventListener('click', () => consentUpdated('SELECTED', dialogContainer, consentUpdateCallback));
}

/**
* Shows a modal dialog with detail information about the different
* categories of cookies the website uses, and enables the users
* to select individually the different categories they want to
* allow or reject
* @param {*} categoriesSections array of div sections containing the categories.
* The first section is considered the introduction, the rest are considered
* a category of cookies each
* @param {Function} consentUpdateCallback callback to invoke when consent is updated
*/
// eslint-disable-next-line import/prefer-default-export
export function buildAndShowDialog(categoriesSections, consentUpdateCallback) {
// eslint-disable-next-line max-len
const selectedCategories = window.consent ? window.consent.categories : [];
// eslint-disable-next-line object-curly-newline, max-len
const infoSection = categoriesSections.shift();
infoSection.classList = 'consent-info-panel';
infoSection.append(consentButtonsPanelHTML());
const ccCategoriesPanel = generateCategoriesPanel(categoriesSections, selectedCategories);
const dialog = document.createElement('dialog');
const dialogContent = document.createElement('div');
dialogContent.classList.add('dialog-content');
dialogContent.append(infoSection, ccCategoriesPanel);
dialog.append(dialogContent);

addCloseButton(dialog);

const dialogContainer = document.createElement('div');
dialogContainer.classList = 'consent';
dialog.addEventListener('close', () => dialogContainer.remove());
document.querySelector('main').append(dialogContainer);
dialogContainer.append(dialog);

addListeners(dialogContainer, consentUpdateCallback);
dialog.showModal();
}
Loading