From ef29fedc260e7d1a1d50e0d54d7b482a26c79fa6 Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Thu, 8 Feb 2024 16:49:00 +0100 Subject: [PATCH 01/19] feat(cookieconsent): cookie consent block --- blocks/cookie-consent/consent-dialog.js | 282 +++++++++++++++++++++++ blocks/cookie-consent/cookie-consent.css | 281 ++++++++++++++++++++++ blocks/cookie-consent/cookie-consent.js | 53 +++++ package-lock.json | 4 +- scripts/scripts.js | 17 ++ 5 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 blocks/cookie-consent/consent-dialog.js create mode 100644 blocks/cookie-consent/cookie-consent.css create mode 100644 blocks/cookie-consent/cookie-consent.js diff --git a/blocks/cookie-consent/consent-dialog.js b/blocks/cookie-consent/consent-dialog.js new file mode 100644 index 00000000..9ffc4750 --- /dev/null +++ b/blocks/cookie-consent/consent-dialog.js @@ -0,0 +1,282 @@ +import { loadFragment } from '../fragment/fragment.js'; +import { + decorateIcons, fetchPlaceholders, +} from '../../scripts/aem.js'; + +function consentUpdated(mode, dialogContainer, consentUpdateCallback, categoriesMap) { + let selectedCategories; + if (categoriesMap) { + selectedCategories = categoriesMap.filter((cat) => (mode === 'ALL' || !cat.optional)) + .map((cat) => cat.code); + } else { + // category list is not passed as a parameter, we get it from the checkboxes + 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(` + `); +} + +function consentCategoriesButtonsPanelHTML() { + const placeholders = fetchPlaceholders(); + return document.createRange().createContextualFragment(` + `); +} + +function categoryHeaderHTML(title, code, optional, selected) { + return ` +
+

${title}

+
+ `; +} + +function addCloseButton(banner) { + const closeButton = document.createElement('button'); + closeButton.classList.add('close-button'); + closeButton.setAttribute('aria-label', 'Close'); + closeButton.type = 'button'; + closeButton.innerHTML = ''; + closeButton.addEventListener('click', () => (banner.close ? banner.close() : banner.remove())); + banner.append(closeButton); + decorateIcons(closeButton); +} + +function closeOnClickOutside(dialog) { + // close dialog on clicks outside the dialog. https://stackoverflow.com/a/70593278/79461 + dialog.addEventListener('click', (event) => { + const dialogDimensions = dialog.getBoundingClientRect(); + if (event.clientX < dialogDimensions.left || event.clientX > dialogDimensions.right + || event.clientY < dialogDimensions.top || event.clientY > dialogDimensions.bottom) { + dialog.close(); + } + }); +} + +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 = `

${placeholders.consentCookieSettings || 'Cookie Settings'}

`; + ccCategoriesSection.append(ccCategoriesSectionTitle); + ccCategoriesSection.append(ccCategoriesDetails); + ccCategoriesSection.append(consentCategoriesButtonsPanelHTML(placeholders)); + return ccCategoriesSection; +} + +function toggleCategoriesPanel(dialogContainer) { + dialogContainer.querySelector('.consent-info-panel').style.display = 'none'; + dialogContainer.querySelector('.consent-categories-panel').style.display = 'block'; +} + +function addListeners(dialogContainer, consentUpdateCallback) { + dialogContainer.querySelector('.consent-select-preferences-link').addEventListener('click', () => toggleCategoriesPanel(dialogContainer, consentUpdateCallback)); + dialogContainer.querySelector('.consent-button.accept').addEventListener('click', () => consentUpdated('ALL', dialogContainer, consentUpdateCallback)); + dialogContainer.querySelectorAll('.consent-button.decline').forEach((b) => b.addEventListener('click', () => consentUpdated('NONE', dialogContainer, consentUpdateCallback))); + dialogContainer.querySelector('.consent-button.only-selected').addEventListener('click', () => consentUpdated('SELECTED', dialogContainer, consentUpdateCallback)); +} + +function getStylingOptions(ds) { + const trueStrs = ['true', 'yes']; + return { + modal: ds.modal ? trueStrs.includes(ds.modal.toLowerCase().trim()) : true, + showCloseButton: ds.closeButton && trueStrs.includes(ds.closeButton.toLowerCase().trim()), + position: ds.position ? ds.position.toLowerCase().trim() : 'center', + // eslint-disable-next-line max-len + closeOnClick: ds.closeOnClickOutside && trueStrs.includes(ds.closeOnClickOutside.toLowerCase().trim()), + // eslint-disable-next-line max-len + displayCategories: ds.displayCategories && trueStrs.includes(ds.displayCategories.toLowerCase().trim()), + }; +} + +function buildAndShowDialog(infoSection, 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 { modal, position, showCloseButton, closeOnClick, displayCategories } = getStylingOptions(infoSection.dataset); + infoSection.classList = 'consent-info-panel'; + infoSection.append(consentButtonsPanelHTML()); + const ccCategoriesPanel = generateCategoriesPanel(categoriesSections, selectedCategories); + + if (displayCategories) { + ccCategoriesPanel.style.display = 'block'; + infoSection.querySelector('.consent-select-preferences').style.visibility = 'hidden'; + ccCategoriesPanel.querySelector('.consent-button.decline').style.display = 'none'; + } + + const dialog = document.createElement('dialog'); + const dialogContent = document.createElement('div'); + dialogContent.classList.add('dialog-content'); + dialogContent.append(infoSection, ccCategoriesPanel); + dialog.append(dialogContent); + + if (showCloseButton) { + addCloseButton(dialog); + } + if (closeOnClick) { + closeOnClickOutside(dialog); + } + + const dialogContainer = document.createElement('div'); + dialog.addEventListener('close', () => dialogContainer.remove()); + dialogContainer.classList.add('consent', position); + if (!modal) { + dialogContainer.classList.add('nomodal'); + } + document.querySelector('main').append(dialogContainer); + dialogContainer.append(dialog); + + addListeners(dialogContainer, consentUpdateCallback); + if (modal) { + dialog.showModal(); + } else { + dialog.show(); + } +} + +/** MINIMAL BANNER functions */ +function addListenersMinimal(container, consentUpdateCallback, categoriesMap, cmpSections) { + const acceptAll = container.querySelector('.consent.minimal .accept'); + const rejectAll = container.querySelector('.consent.minimal .decline'); + const moreInformation = container.querySelector('.consent.minimal .more-info'); + + if (acceptAll) { + acceptAll.addEventListener('click', () => consentUpdated('ALL', container, consentUpdateCallback, categoriesMap)); + } + if (rejectAll) { + rejectAll.addEventListener('click', () => consentUpdated('NONE', container, consentUpdateCallback, categoriesMap)); + } + if (moreInformation && cmpSections) { + moreInformation.addEventListener('click', () => { + buildAndShowDialog(cmpSections.shift(), cmpSections, consentUpdateCallback); + container.remove(); + }); + } +} + +function getCategoriesInMinimalBanner(minimalSection, categoriesSections) { + if (minimalSection.getAttribute('data-required-cookies') || minimalSection.getAttribute('data-optional-cookies')) { + const categories = []; + if (minimalSection.getAttribute('data-required-cookies')) { + minimalSection.getAttribute('data-required-cookies').split(',') + .map((c) => c.trim()) + .forEach((c) => categories.push({ code: c, optional: false })); + } + + if (minimalSection.getAttribute('data-optional-cookies')) { + minimalSection.getAttribute('data-optional-cookies').split(',') + .map((c) => c.trim()) + .forEach((c) => categories.push({ code: c, optional: true })); + } + return categories; + } + + 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 }]; +} + +function createMinimalBanner(section) { + const content = section.childNodes; + const buttonString = section.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', 'minimal'); + div.append(...content); + const acceptAllButton = ``; + const rejectAllButton = ``; + const moreInfoLink = `${placeholders.moreInformation || 'More Information'}`; + 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; +} +/** END MINIMAL BANNER */ +// eslint-disable-next-line import/prefer-default-export +export async function showConsentBanner(path, consentUpdateCallback) { + const fragment = await loadFragment(path); + if (!fragment) { + return; + } + const cmpSections = [...fragment.querySelectorAll('div.section')]; + const firstSection = cmpSections.shift(); + if (firstSection.classList.contains('minimal')) { + const minimalDialog = createMinimalBanner(firstSection); + document.querySelector('main').append(minimalDialog); + const categoriesMap = getCategoriesInMinimalBanner(firstSection, cmpSections); + addListenersMinimal(minimalDialog, consentUpdateCallback, categoriesMap, cmpSections); + } else { + buildAndShowDialog(firstSection, cmpSections, consentUpdateCallback); + } +} diff --git a/blocks/cookie-consent/cookie-consent.css b/blocks/cookie-consent/cookie-consent.css new file mode 100644 index 00000000..76ff11bb --- /dev/null +++ b/blocks/cookie-consent/cookie-consent.css @@ -0,0 +1,281 @@ +.consent { + font-size: var(--body-font-size-s); +} + +.consent h2 { + font-size: var(--heading-font-size-m); +} + +.consent dialog { + --dialog-border-radius: 16px; + overscroll-behavior: none; + padding: 30px; + border: 1px solid #ccc; + border-radius: var(--dialog-border-radius); + left: 20px; + right: 20px; + width: clamp(300px, 80%, 700px); +} + +.consent dialog::backdrop { + background-color: rgb(0 0 0 / 50%); +} + +.consent.bottom dialog { + bottom: 0px; + left: 0px; + width: 100%; + margin: 0px; + inset-block-start: auto; + border-radius: 0px; + position: fixed; + padding: 30px; + max-width: calc(100% - 6px - 2em); + max-height: calc(100% - 6px - 2em); +} + +.consent.nomodal.center dialog { + inset-block-start: 0; + inset-block-end: 0; + border-radius: 0px; + position: fixed; + padding: 30px; +} + +.consent .close-button { + position: absolute; + top: 0; + right: 0; + width: 20px; + height: 100%; + max-height: 54px; + border-radius: 0 var(--dialog-border-radius) 0 0; + background-color: unset; + text-overflow: clip; + margin: 0; + border: none; + padding-right: 30px; + padding-left: 0px; +} + +.consent.bottom .close-button { + right: 20px; +} + +.consent dialog .section { + padding: 0; +} + +.consent .consent-info-panel { + display: block; +} + +.consent .consent-categories-panel { + display: none; +} + +.consent .consent-categories-panel .accordion { + margin-top: 40px; +} + +.consent .consent-categories-panel .consent-buttons-preferences { + padding-top: 40px; + width: 100%; + display: flex; + justify-content: right; + column-gap: 10px; +} + +.consent-controls { + display: flex; + justify-content: space-between; +} + +.consent-button { + font-size: var(--body-font-size-s); + margin: 0px; +} + +.consent-category-header { + display: flex; + justify-content: space-between; +} + +.consent-category { + padding-top: 10px; +} + +.consent.minimal { + position: fixed; + bottom: 3px; + width: 100%; + text-align: center; + background-color: rgba(180, 176, 176, 0.95); + z-index: 10; + padding: 10px 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} + +.consent.minimal .default-content-wrapper { + padding-right: 20px; +} + +.consent.minimal .default-content-wrapper p { + padding: 5px; + margin: 5px; + border: 0px; +} + +.consent.minimal .controls { + padding-left: 20px; + padding-right: 20px; +} + +.consent.minimal .consent-button { + padding: 5px; + margin: 5px; + border: none; + background: none; + font-weight: normal; +} + +.consent.minimal .consent-button.primary { + color: var(--link-color); +} + +.consent.minimal .consent-button.secondary { + color: var(--light-color); +} + +.consent.minimal .more-info { + color: var(--link-color); +} + +/* TOGGLE SWITCH */ +.consent-category-switch { + display: flex; + justify-content: center; + align-items: center; +} + +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 60px; + height: 34px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: 0.4s; +} + +.slider::before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background-color: white; + transition: 0.4s; +} + +input:checked+.slider { + background-color: #2196f3; +} + +input:focus+.slider { + box-shadow: 0 0 9px 3px #015396; +} + +input:checked+.slider::before { + transform: translateX(26px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} + +.slider.round::before { + border-radius: 50%; +} + +/*** COPIED FROM ACCORDION ***/ +.accordion details { + border: 1px solid var(--dark-color); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.accordion details+details { + margin-top: 16px; +} + +.accordion details summary { + position: relative; + padding: 0 16px; + padding-right: 48px; + cursor: pointer; + list-style: none; + overflow: auto; + transition: background-color 0.2s; +} + +.accordion details[open] summary { + background-color: var(--light-color); +} + +.accordion details summary:focus, +.accordion details summary:hover { + background-color: var(--dark-color); +} + +.accordion details summary::-webkit-details-marker { + display: none; +} + +.accordion details summary::after { + content: ""; + position: absolute; + top: 50%; + right: 18px; + transform: translateY(-50%) rotate(135deg); + width: 9px; + height: 9px; + border: 2px solid; + border-width: 2px 2px 0 0; + transition: transform 0.2s; +} + +.accordion details[open] summary::after { + transform: translateY(-50%) rotate(-45deg); +} + +.accordion details .accordion-item-body { + padding: 0 16px; +} + +.accordion details[open] .accordion-item-body { + border-top: 1px solid var(--dark-color); + background-color: var(--background-color); +} \ No newline at end of file diff --git a/blocks/cookie-consent/cookie-consent.js b/blocks/cookie-consent/cookie-consent.js new file mode 100644 index 00000000..74ce87ff --- /dev/null +++ b/blocks/cookie-consent/cookie-consent.js @@ -0,0 +1,53 @@ +import { + sampleRUM, +} from '../../scripts/aem.js'; + +const LOCAL_STORAGE_AEM_CONSENT = 'aem-consent'; + +function userCookiePreferences(categories) { + // eslint-disable-next-line max-len + const storage = localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT) ? JSON.parse(localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT)) : {}; + if (!categories) { + window.hlx.consent.categories = categories; + return storage.categories; + } + storage.categories = categories; + localStorage.setItem(LOCAL_STORAGE_AEM_CONSENT, JSON.stringify(storage)); + window.hlx = window.hlx || []; + window.hlx.consent.categories = categories; + return categories; +} + +/** + * updates consent categories in local storage, + * triggers downstream consent-update event, + * tracks the selection in RUM + * @param {Array} selCategories + */ +export function manageConsentUpdate(selCategories) { + const newCategories = Array.isArray(selCategories) ? selCategories : [selCategories]; + userCookiePreferences(newCategories); + sampleRUM('consentupdate', newCategories); + const consentUpdateEvent = new CustomEvent('consent-updated', newCategories); + dispatchEvent(consentUpdateEvent); +} + +function manageConsentRead(categories) { + sampleRUM('consent', categories); + const consentReadEvent = new CustomEvent('consent', categories); + dispatchEvent(consentReadEvent); +} + +export default function decorate(block) { + block.closest('.section').remove(); + const path = block.textContent.trim(); + const selectedCategories = userCookiePreferences(); + if (selectedCategories && selectedCategories.length > 0) { + window.hlx = window.hlx || {}; + window.hlx.consent.categories = selectedCategories; + manageConsentRead(selectedCategories); + } else { + block.remove(); + import('./consent-dialog.js').then((ccdialog) => ccdialog.showConsentBanner(path, manageConsentUpdate)); + } +} diff --git a/package-lock.json b/package-lock.json index 34edd24a..650205a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@adobe/helix-project-block-collection", + "name": "@adobe/helix-project-boilerplate", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@adobe/helix-project-block-collection", + "name": "@adobe/helix-project-boilerplate", "version": "1.0.0", "license": "Apache License 2.0", "devDependencies": { diff --git a/scripts/scripts.js b/scripts/scripts.js index 84f08c80..bb96cfff 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -16,6 +16,22 @@ import { const LCP_BLOCKS = []; // add your LCP blocks to the list +function buildCookieConsent(main) { + const ccPath = getMetadata('cookie-consent'); + if (!ccPath || (window.hlx && window.hlx.consent)) { + // consent not configured for page or already initialized + return; + } + window.hlx = window.hlx || []; + window.hlx.consent = { status: 'pending' }; + const blockHTML = `
${ccPath}
`; + const section = document.createElement('div'); + const ccBlock = document.createElement('div'); + ccBlock.innerHTML = blockHTML; + section.append(buildBlock('cookie-consent', ccBlock)); + main.append(section); +} + /** * Builds hero block and prepends to main in a new section. * @param {Element} main The container element @@ -62,6 +78,7 @@ function autolinkModals(element) { function buildAutoBlocks(main) { try { buildHeroBlock(main); + buildCookieConsent(main); } catch (error) { // eslint-disable-next-line no-console console.error('Auto Blocking failed', error); From 8a0387b348139c0f04ccb74ea9c8bfd25444b34e Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Thu, 8 Feb 2024 22:39:04 +0100 Subject: [PATCH 02/19] fix: button styles --- blocks/cookie-consent/consent-dialog.js | 10 ++-- blocks/cookie-consent/cookie-consent.css | 58 ++++++++++++++---------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/blocks/cookie-consent/consent-dialog.js b/blocks/cookie-consent/consent-dialog.js index 9ffc4750..7e1a05e1 100644 --- a/blocks/cookie-consent/consent-dialog.js +++ b/blocks/cookie-consent/consent-dialog.js @@ -14,6 +14,7 @@ function consentUpdated(mode, dialogContainer, consentUpdateCallback, categories .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 @@ -128,7 +129,10 @@ function toggleCategoriesPanel(dialogContainer) { } function addListeners(dialogContainer, consentUpdateCallback) { - dialogContainer.querySelector('.consent-select-preferences-link').addEventListener('click', () => toggleCategoriesPanel(dialogContainer, consentUpdateCallback)); + const preferencesLink = dialogContainer.querySelector('.consent-select-preferences-link'); + if (preferencesLink) { + preferencesLink.addEventListener('click', () => toggleCategoriesPanel(dialogContainer, consentUpdateCallback)); + } dialogContainer.querySelector('.consent-button.accept').addEventListener('click', () => consentUpdated('ALL', dialogContainer, consentUpdateCallback)); dialogContainer.querySelectorAll('.consent-button.decline').forEach((b) => b.addEventListener('click', () => consentUpdated('NONE', dialogContainer, consentUpdateCallback))); dialogContainer.querySelector('.consent-button.only-selected').addEventListener('click', () => consentUpdated('SELECTED', dialogContainer, consentUpdateCallback)); @@ -158,8 +162,8 @@ function buildAndShowDialog(infoSection, categoriesSections, consentUpdateCallba if (displayCategories) { ccCategoriesPanel.style.display = 'block'; - infoSection.querySelector('.consent-select-preferences').style.visibility = 'hidden'; - ccCategoriesPanel.querySelector('.consent-button.decline').style.display = 'none'; + infoSection.querySelector('.consent-select-preferences').innerHTML = ''; + ccCategoriesPanel.querySelector('.consent-button.decline').remove(); } const dialog = document.createElement('dialog'); diff --git a/blocks/cookie-consent/cookie-consent.css b/blocks/cookie-consent/cookie-consent.css index 76ff11bb..44cdaad8 100644 --- a/blocks/cookie-consent/cookie-consent.css +++ b/blocks/cookie-consent/cookie-consent.css @@ -86,22 +86,30 @@ column-gap: 10px; } -.consent-controls { +.consent .consent-controls { display: flex; + gap: 10px 10px; + flex-wrap: wrap; justify-content: space-between; } -.consent-button { +.consent .consent-controls .consent-buttons { + gap: 10px 10px; + display: flex; + justify-content: flex-end; +} + +.consent .consent-button { font-size: var(--body-font-size-s); margin: 0px; } -.consent-category-header { +.consent .consent-category-header { display: flex; justify-content: space-between; } -.consent-category { +.consent .consent-category { padding-top: 10px; } @@ -156,14 +164,14 @@ } /* TOGGLE SWITCH */ -.consent-category-switch { +.consent .consent-category-switch { display: flex; justify-content: center; align-items: center; } /* The switch - the box around the slider */ -.switch { +.consent .switch { position: relative; display: inline-block; width: 60px; @@ -171,14 +179,14 @@ } /* Hide default HTML checkbox */ -.switch input { +.consent .switch input { opacity: 0; width: 0; height: 0; } /* The slider */ -.slider { +.consent .slider { position: absolute; cursor: pointer; top: 0; @@ -189,7 +197,7 @@ transition: 0.4s; } -.slider::before { +.consent .slider::before { position: absolute; content: ""; height: 26px; @@ -200,38 +208,38 @@ transition: 0.4s; } -input:checked+.slider { +.consent input:checked+.slider { background-color: #2196f3; } -input:focus+.slider { +.consent input:focus+.slider { box-shadow: 0 0 9px 3px #015396; } -input:checked+.slider::before { +.consent input:checked+.slider::before { transform: translateX(26px); } /* Rounded sliders */ -.slider.round { +.consent .slider.round { border-radius: 34px; } -.slider.round::before { +.consent .slider.round::before { border-radius: 50%; } /*** COPIED FROM ACCORDION ***/ -.accordion details { +.consent .accordion details { border: 1px solid var(--dark-color); } /* stylelint-disable-next-line no-descending-specificity */ -.accordion details+details { +.consent .accordion details+details { margin-top: 16px; } -.accordion details summary { +.consent .accordion details summary { position: relative; padding: 0 16px; padding-right: 48px; @@ -241,20 +249,20 @@ input:checked+.slider::before { transition: background-color 0.2s; } -.accordion details[open] summary { +.consent .accordion details[open] summary { background-color: var(--light-color); } -.accordion details summary:focus, -.accordion details summary:hover { +.consent .accordion details summary:focus, +.consent .accordion details summary:hover { background-color: var(--dark-color); } -.accordion details summary::-webkit-details-marker { +.consent .accordion details summary::-webkit-details-marker { display: none; } -.accordion details summary::after { +.consent .accordion details summary::after { content: ""; position: absolute; top: 50%; @@ -267,15 +275,15 @@ input:checked+.slider::before { transition: transform 0.2s; } -.accordion details[open] summary::after { +.consent .accordion details[open] summary::after { transform: translateY(-50%) rotate(-45deg); } -.accordion details .accordion-item-body { +.consent .accordion details .accordion-item-body { padding: 0 16px; } -.accordion details[open] .accordion-item-body { +.consent .accordion details[open] .accordion-item-body { border-top: 1px solid var(--dark-color); background-color: var(--background-color); } \ No newline at end of file From 2908fd9810cefc6f5fde692be2de03a313a5f000 Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Thu, 8 Feb 2024 22:46:42 +0100 Subject: [PATCH 03/19] fix: linting --- blocks/cookie-consent/cookie-consent.css | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/blocks/cookie-consent/cookie-consent.css b/blocks/cookie-consent/cookie-consent.css index 44cdaad8..8fc02506 100644 --- a/blocks/cookie-consent/cookie-consent.css +++ b/blocks/cookie-consent/cookie-consent.css @@ -7,7 +7,6 @@ } .consent dialog { - --dialog-border-radius: 16px; overscroll-behavior: none; padding: 30px; border: 1px solid #ccc; @@ -22,12 +21,12 @@ } .consent.bottom dialog { - bottom: 0px; - left: 0px; + bottom: 0; + left: 0; width: 100%; - margin: 0px; + margin: 0; inset-block-start: auto; - border-radius: 0px; + border-radius: 0; position: fixed; padding: 30px; max-width: calc(100% - 6px - 2em); @@ -37,7 +36,7 @@ .consent.nomodal.center dialog { inset-block-start: 0; inset-block-end: 0; - border-radius: 0px; + border-radius: 0; position: fixed; padding: 30px; } @@ -55,7 +54,7 @@ margin: 0; border: none; padding-right: 30px; - padding-left: 0px; + padding-left: 0; } .consent.bottom .close-button { @@ -101,7 +100,7 @@ .consent .consent-button { font-size: var(--body-font-size-s); - margin: 0px; + margin: 0; } .consent .consent-category-header { @@ -118,14 +117,13 @@ bottom: 3px; width: 100%; text-align: center; - background-color: rgba(180, 176, 176, 0.95); + background-color: rgb(180 176 176 / 95%); z-index: 10; padding: 10px 0; display: flex; - flex-direction: row; align-items: center; justify-content: center; - flex-wrap: wrap; + flex-flow: row wrap; } .consent.minimal .default-content-wrapper { @@ -135,7 +133,7 @@ .consent.minimal .default-content-wrapper p { padding: 5px; margin: 5px; - border: 0px; + border: 0; } .consent.minimal .controls { @@ -208,15 +206,15 @@ transition: 0.4s; } -.consent input:checked+.slider { +.consent input:checked + .slider { background-color: #2196f3; } -.consent input:focus+.slider { +.consent input:focus + .slider { box-shadow: 0 0 9px 3px #015396; } -.consent input:checked+.slider::before { +.consent input:checked + .slider::before { transform: translateX(26px); } @@ -235,7 +233,7 @@ } /* stylelint-disable-next-line no-descending-specificity */ -.consent .accordion details+details { +.consent .accordion details + details { margin-top: 16px; } @@ -286,4 +284,4 @@ .consent .accordion details[open] .accordion-item-body { border-top: 1px solid var(--dark-color); background-color: var(--background-color); -} \ No newline at end of file +} From ca765b198591bf2278901ebb32c7a55a878a533b Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Fri, 9 Feb 2024 11:21:11 +0100 Subject: [PATCH 04/19] chore: enable consent dialog to update preferences --- blocks/cookie-consent/consent-dialog.js | 20 ++++++++++++++++++++ blocks/cookie-consent/cookie-consent.js | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/blocks/cookie-consent/consent-dialog.js b/blocks/cookie-consent/consent-dialog.js index 7e1a05e1..8e801b79 100644 --- a/blocks/cookie-consent/consent-dialog.js +++ b/blocks/cookie-consent/consent-dialog.js @@ -284,3 +284,23 @@ export async function showConsentBanner(path, consentUpdateCallback) { buildAndShowDialog(firstSection, cmpSections, consentUpdateCallback); } } + +/** + * shows the consent banner to update the preferences + * ignoring the minimal setup if present + * @param {*} path + * @param {*} consentUpdateCallback + * @returns + */ +export async function showConsentBannerForUpdate(path, consentUpdateCallback) { + const fragment = await loadFragment(path); + if (!fragment) { + return; + } + const cmpSections = [...fragment.querySelectorAll('div.section')]; + const firstSection = cmpSections.shift(); + if (firstSection.classList.contains('minimal') && cmpSections.length) { + const infoSection = cmpSections.shift(); + buildAndShowDialog(infoSection, cmpSections, consentUpdateCallback); + } +} diff --git a/blocks/cookie-consent/cookie-consent.js b/blocks/cookie-consent/cookie-consent.js index 74ce87ff..a4d84d2e 100644 --- a/blocks/cookie-consent/cookie-consent.js +++ b/blocks/cookie-consent/cookie-consent.js @@ -51,3 +51,11 @@ export default function decorate(block) { import('./consent-dialog.js').then((ccdialog) => ccdialog.showConsentBanner(path, manageConsentUpdate)); } } + +/** + * shows the consent dialog to update the preferences once they have been selected + * @param {String} path to the document with the dialog information + */ +export function showUpdateConsentDialog(path) { + import('./consent-dialog.js').then((ccdialog) => ccdialog.showConsentBannerForUpdate(path, manageConsentUpdate)); +} From 7ebd36017a33452437d822d39b3a06758cd5b01d Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Fri, 9 Feb 2024 11:49:58 +0100 Subject: [PATCH 05/19] chore: refactor local storage --- blocks/cookie-consent/consent-dialog.js | 4 +++- blocks/cookie-consent/cookie-consent.js | 29 ++++++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/blocks/cookie-consent/consent-dialog.js b/blocks/cookie-consent/consent-dialog.js index 8e801b79..775f601b 100644 --- a/blocks/cookie-consent/consent-dialog.js +++ b/blocks/cookie-consent/consent-dialog.js @@ -299,8 +299,10 @@ export async function showConsentBannerForUpdate(path, consentUpdateCallback) { } const cmpSections = [...fragment.querySelectorAll('div.section')]; const firstSection = cmpSections.shift(); - if (firstSection.classList.contains('minimal') && cmpSections.length) { + if (firstSection.classList.contains('minimal') && cmpSections.length > 0) { const infoSection = cmpSections.shift(); buildAndShowDialog(infoSection, cmpSections, consentUpdateCallback); + } else { + buildAndShowDialog(firstSection, cmpSections, consentUpdateCallback); } } diff --git a/blocks/cookie-consent/cookie-consent.js b/blocks/cookie-consent/cookie-consent.js index a4d84d2e..501a8b1c 100644 --- a/blocks/cookie-consent/cookie-consent.js +++ b/blocks/cookie-consent/cookie-consent.js @@ -4,18 +4,17 @@ import { const LOCAL_STORAGE_AEM_CONSENT = 'aem-consent'; -function userCookiePreferences(categories) { +function getStoredPreference() { + // eslint-disable-next-line max-len + const storage = localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT) ? JSON.parse(localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT)) : {}; + return storage.categories; +} + +function setStoredPreference(categories) { // eslint-disable-next-line max-len const storage = localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT) ? JSON.parse(localStorage.getItem(LOCAL_STORAGE_AEM_CONSENT)) : {}; - if (!categories) { - window.hlx.consent.categories = categories; - return storage.categories; - } storage.categories = categories; localStorage.setItem(LOCAL_STORAGE_AEM_CONSENT, JSON.stringify(storage)); - window.hlx = window.hlx || []; - window.hlx.consent.categories = categories; - return categories; } /** @@ -24,15 +23,21 @@ function userCookiePreferences(categories) { * tracks the selection in RUM * @param {Array} selCategories */ -export function manageConsentUpdate(selCategories) { +function manageConsentUpdate(selCategories) { const newCategories = Array.isArray(selCategories) ? selCategories : [selCategories]; - userCookiePreferences(newCategories); + window.hlx = window.hlx || {}; + window.hlx.consent.status = 'done'; + window.hlx.consent.categories = newCategories; + setStoredPreference(newCategories); sampleRUM('consentupdate', newCategories); const consentUpdateEvent = new CustomEvent('consent-updated', newCategories); dispatchEvent(consentUpdateEvent); } function manageConsentRead(categories) { + window.hlx = window.hlx || {}; + window.hlx.consent.status = 'done'; + window.hlx.consent.categories = categories; sampleRUM('consent', categories); const consentReadEvent = new CustomEvent('consent', categories); dispatchEvent(consentReadEvent); @@ -41,10 +46,8 @@ function manageConsentRead(categories) { export default function decorate(block) { block.closest('.section').remove(); const path = block.textContent.trim(); - const selectedCategories = userCookiePreferences(); + const selectedCategories = getStoredPreference(); if (selectedCategories && selectedCategories.length > 0) { - window.hlx = window.hlx || {}; - window.hlx.consent.categories = selectedCategories; manageConsentRead(selectedCategories); } else { block.remove(); From a33f9097c542e3aca0d1814a58fe878b41b4324b Mon Sep 17 00:00:00 2001 From: cuzuco2 Date: Fri, 9 Feb 2024 11:51:39 +0100 Subject: [PATCH 06/19] added call to cookie dialog from footer --- blocks/footer/footer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index e1dbe938..86b838b8 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -1,5 +1,6 @@ import { getMetadata } from '../../scripts/aem.js'; import { loadFragment } from '../fragment/fragment.js'; +import { showUpdateConsentDialog } from '../cookie-consent/cookie-consent.js'; /** * loads and decorates the footer @@ -12,6 +13,12 @@ export default async function decorate(block) { // load footer fragment const footerPath = footerMeta.footer || '/footer'; const fragment = await loadFragment(footerPath); + if (getMetadata('cookie-consent')) { + fragment.querySelector('a[title="Cookie preferences"]').addEventListener('click', (e) => { + showUpdateConsentDialog(getMetadata('cookie-consent')); + e.preventDefault(); + }); + } // decorate footer DOM const footer = document.createElement('div'); From 775d94304a7c306efdcf71e86f205e98f2800b21 Mon Sep 17 00:00:00 2001 From: Francisco Chicharro Sanz Date: Fri, 16 Feb 2024 15:51:28 +0100 Subject: [PATCH 07/19] cookie consent simplify and clean up the code --- blocks/cookie-consent/consent-banner.js | 166 +++++++++++++++++ blocks/cookie-consent/consent-dialog.js | 222 ++++------------------- blocks/cookie-consent/cookie-consent.css | 139 ++++++-------- blocks/cookie-consent/cookie-consent.js | 9 +- blocks/footer/footer.js | 11 +- 5 files changed, 263 insertions(+), 284 deletions(-) create mode 100644 blocks/cookie-consent/consent-banner.js diff --git a/blocks/cookie-consent/consent-banner.js b/blocks/cookie-consent/consent-banner.js new file mode 100644 index 00000000..7d0ba4bf --- /dev/null +++ b/blocks/cookie-consent/consent-banner.js @@ -0,0 +1,166 @@ +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 = ''; + 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 = ``; + const rejectAllButton = ``; + const moreInfoLink = `${placeholders.moreInformation || 'More Information'}`; + 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) { + 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); + } +} diff --git a/blocks/cookie-consent/consent-dialog.js b/blocks/cookie-consent/consent-dialog.js index 775f601b..b6dd1975 100644 --- a/blocks/cookie-consent/consent-dialog.js +++ b/blocks/cookie-consent/consent-dialog.js @@ -1,19 +1,19 @@ -import { loadFragment } from '../fragment/fragment.js'; import { decorateIcons, fetchPlaceholders, } from '../../scripts/aem.js'; -function consentUpdated(mode, dialogContainer, consentUpdateCallback, categoriesMap) { - let selectedCategories; - if (categoriesMap) { - selectedCategories = categoriesMap.filter((cat) => (mode === 'ALL' || !cat.optional)) - .map((cat) => cat.code); - } else { - // category list is not passed as a parameter, we get it from the checkboxes - 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); - } +/** + * + * @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); @@ -25,14 +25,9 @@ function consentUpdated(mode, dialogContainer, consentUpdateCallback, categories function consentButtonsPanelHTML() { const placeholders = fetchPlaceholders(); return document.createRange().createContextualFragment(` -