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 17 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
167 changes: 167 additions & 0 deletions blocks/cookie-consent/consent-banner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
fetchPlaceholders,
decorateIcons,
} 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 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 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 = `<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);
}

addCloseButton(div);
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/cookie-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.hlx && window.hlx.consent) ? window.hlx.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