diff --git a/.azurite/__azurite_db_blob__.json b/.azurite/__azurite_db_blob__.json new file mode 100644 index 00000000..cc73052f --- /dev/null +++ b/.azurite/__azurite_db_blob__.json @@ -0,0 +1 @@ +{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob__.json","collections":[{"name":"$SERVICES_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{},"constraints":null,"uniqueNames":["accountName"],"transforms":{},"objType":"$SERVICES_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$CONTAINERS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$CONTAINERS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOBS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]},"snapshot":{"name":"snapshot","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOBS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOCKS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"blobName":{"name":"blobName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOCKS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"} \ No newline at end of file diff --git a/.azurite/__azurite_db_blob_extent__.json b/.azurite/__azurite_db_blob_extent__.json new file mode 100644 index 00000000..f4f40d61 --- /dev/null +++ b/.azurite/__azurite_db_blob_extent__.json @@ -0,0 +1 @@ +{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob_extent__.json","collections":[{"name":"$EXTENTS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"id":{"name":"id","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$EXTENTS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"} \ No newline at end of file diff --git a/packages/cookie-banner/.npmignore b/packages/cookie-banner/.npmignore index 97d59ca6..e9c7665c 100644 --- a/packages/cookie-banner/.npmignore +++ b/packages/cookie-banner/.npmignore @@ -1,6 +1,5 @@ .DS_Store *.log -src __tests__ example coverage diff --git a/packages/cookie-banner/README.md b/packages/cookie-banner/README.md index 19c92bd4..62330366 100644 --- a/packages/cookie-banner/README.md +++ b/packages/cookie-banner/README.md @@ -4,29 +4,14 @@ GDPR compliant cookie banner and consent form. Renders a cookie banner and a consent form based on configuration settings, and conditionally invokes cookie-reliant functionality based on user consent. +A WCAG and GDPR compliant example is available for reference at https://storm-ui-patterns.netlify.app/patterns/cookie-banner/. + --- ## Usage -Cookie consent is based on categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition. - -The cookie banner renders itself if no consent preferences are recorded in the browser. - -The consent form renders into a DOMElement with a particular className configurable options (classNames.formContainer). - -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. +Cookie consent management works by categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition. -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', - analytics_storage: 'test' -} -``` +The cookie banner renders itself if no consent preferences are recorded in the browser, or if recorded consent categories do not match. Install the package @@ -34,12 +19,12 @@ Install the package npm i -S @stormid/cookie-banner ``` -Create a container element for the consent form. +Create a container element for the consent form. The consent form renders into a DOMElement with a className matching the classNames.formContainer option. ```
``` -Create a visually hidden live region for the screen reader announcement. +A page containing a cookie consent form should also include a visually hidden live region (role=alert) with a className matching the classNames.formAnnouncement option. ``` ``` @@ -51,7 +36,6 @@ import banner from '@stormid/cookie-banner'; const cookieBanner = banner({ types: { 'performance': { - suggested: true, //set as pre-checked on consent form as a suggested response 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: { @@ -84,7 +68,21 @@ const cookieBanner = banner({ }); ``` +## Google EU consent mode +Optionally the banner also supports Google EU consent mode v2 https://developers.google.com/tag-platform/security/guides/consent, and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to support 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: 'Marketing', + ad_user_data: 'Marketing', + ad_personalization: 'Marketing', + analytics_storage: 'Performance' +} +``` + ## Options +Full options that can be passed during initialisation: ``` { name: '.CookiePreferences', //name of the cookie set to record user consent @@ -116,7 +114,7 @@ const cookieBanner = banner({ savedMessage: 'Your settings have been saved.', //displayed after consent form update, trapTab: false, //trap the user's keyboard tab within the banner when open bannerTemplate(model){ - return ``; + `; }, messageTemplate(model){ return `
${model.settings.savedMessage}
` @@ -176,7 +174,7 @@ const cookieBanner = banner({ ``` ## Utility functions -There are two utility functions provided by the library designed to be invoked following user consent. +There are two utility functions provided by the library that can be invoked following user consent. ### Render iframe `state.utils.renderIframe` diff --git a/packages/cookie-banner/__tests__/banner/index.js b/packages/cookie-banner/__tests__/banner/index.js index 68ff38a2..a173e9ee 100644 --- a/packages/cookie-banner/__tests__/banner/index.js +++ b/packages/cookie-banner/__tests__/banner/index.js @@ -71,11 +71,11 @@ describe(`Cookie banner > DOM > not render`, () => { describe(`Cookie banner > DOM > accessibility`, () => { beforeAll(init); - it('The banner should be a dialog', async () => { - expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('dialog'); + it('The banner should be a region', async () => { + expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('region'); }); - it('The banner should have be polite aria live region', async () => { + it('The banner should be a polite aria live region', async () => { expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('aria-live')).toEqual('polite'); }); diff --git a/packages/cookie-banner/__tests__/state.js b/packages/cookie-banner/__tests__/state.js index 3d3f090a..c79d8689 100644 --- a/packages/cookie-banner/__tests__/state.js +++ b/packages/cookie-banner/__tests__/state.js @@ -7,24 +7,6 @@ const init = () => { }; -describe(`Cookie banner > state > init`, () => { - beforeAll(init); - - it('Should return the Store.getState method on initialisation', async () => { - const Store = cookieBanner({ types: {} }); - expect(Store.getState).not.toBeUndefined(); - }); - - it('Should return the state Object from Store.getState', async () => { - const Store = cookieBanner({ types: {} }); - - expect(Store.getState()).toBeDefined(); - expect(Store.getState().consent).toEqual({}); - expect(Store.getState().settings).toBeDefined(); - }); - -}); - describe(`Cookie banner > state > update/reducers`, () => { beforeAll(init); diff --git a/packages/cookie-banner/__tests__/store.js b/packages/cookie-banner/__tests__/store.js new file mode 100644 index 00000000..f173f7aa --- /dev/null +++ b/packages/cookie-banner/__tests__/store.js @@ -0,0 +1,40 @@ +import { createStore } from '../src/lib/store'; + +describe(`Cookie banner > Store`, () => { + + const Store = createStore(); + let effect = false; + const sideEffect = state => { + effect = !effect; + }; + + it('createStore should return an Object with an API', async () => { + expect(Store).not.toBeNull(); + expect(Store.getState).not.toBeNull(); + expect(Store.update).not.toBeNull(); + }); + + it('should have a getState function that returns a private state Object', async () => { + expect(Store.state).toBeUndefined(); + expect(Store.getState()).toEqual({}); + }); + + it('should have an update function that updates state', async () => { + const nextState = { isOpen: true }; + Store.update(nextState); + expect(Store.getState()).toEqual(nextState); + }); + + it('should have an update function that does not update state if nextState is not passed', async () => { + const Store = createStore(); + Store.update(); + expect(Store.getState()).toEqual({}); + }); + + it('should have an update function that invokes any side effect functions passed after the state change, with new null or undefined state', async () => { + Store.update(null, [sideEffect]); + expect(effect).toEqual(true); + }); + + +}); diff --git a/packages/cookie-banner/__tests__/utils.js b/packages/cookie-banner/__tests__/utils.js index e31bd7c6..56029478 100644 --- a/packages/cookie-banner/__tests__/utils.js +++ b/packages/cookie-banner/__tests__/utils.js @@ -100,10 +100,20 @@ describe('Cookie > Utils > extractFromCookie > malformed JSON cookie', () => { }); +describe('Cookie > Utils > extractFromCookie > category mismatch', () => { + it('should return default hasCookie and content properties if categroies do not match', () => { + document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`; + const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {}, ads: {} } }); + + expect(hasCookie).toEqual(false); + expect(consent).toEqual({ }); + }); +}); + describe('Cookie > Utils > extractFromCookie > well-formed JSON cookie', () => { it('should return hasCookie and content properties from well-formed JSON cookie', () => { document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`; - const [hasCookie, consent ] = extractFromCookie(defaults); + const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {} } }); expect(hasCookie).toEqual(true); expect(consent).toEqual({ performance: 1, thirdParty: 0 }); @@ -135,14 +145,14 @@ describe(`Cookie banner > Utils > broadcast`, () => { bannerOpen: true, settings: defaults }; - Store.update(_ => _, state); + Store.update(state); const listener = jest.fn(); - document.addEventListener(EVENTS.OPEN, listener); - document.addEventListener(EVENTS.OPEN, e => { + document.addEventListener(EVENTS.SHOW, listener); + document.addEventListener(EVENTS.SHOW, e => { expect(e.detail).toEqual({ getState: Store.getState }); }); - broadcast(EVENTS.OPEN, Store)(state); + broadcast(EVENTS.SHOW, Store)(state); expect(listener).toHaveBeenCalled(); }); diff --git a/packages/cookie-banner/example/src/index.html b/packages/cookie-banner/example/src/index.html index 015be46d..32be4d83 100644 --- a/packages/cookie-banner/example/src/index.html +++ b/packages/cookie-banner/example/src/index.html @@ -23,25 +23,54 @@ } h1 { font: 1.25rem/1.2 sans-serif; - margin-bottom:1.5rem; + margin-top: 1.5rem; } h2 { font: 1.1rem/1.2 sans-serif; - margin-bottom:12px; + margin-top:12px; padding-top:12px; } h3 { font: 1rem/1.2 sans-serif; - margin-bottom:12px; + margin-top:12px; padding-top:12px; } p { - margin-bottom:1.5rem; + margin-top:1.5rem; } - header { - width:100%; - padding:20px; - margin-bottom:40px; + fieldset { + padding: 0; + margin: 0; + bordeR: 0 none; + } + legend { + display: block; + } + .container { + margin: 24px auto 48px; + width: calc(100% - 48px); + } + .embed iframe, + .embed__placeholder { + width: 600px; + aspect-ratio: 16 / 9; + } + .embed__placeholder { + background-color: hsl(0, 0%, 90%); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; + } + .privacy-banner__btn-text, + .embed__placeholder-btn, + .privacy-banner__link { + background-color: transparent; + color: blue; + text-decoration: underline; + font-size: 1rem; + padding: 0; } .privacy-banner__title { font-size:1.2rem; @@ -54,22 +83,54 @@ right:0; padding:24px 25px; /* border-top:1px solid #191919; */ - box-shadow: 0 1px 3px 1px rgba(60,64,67,.2), 0 2px 8px 4px rgba(60,64,67,.1) + z-index: 1000; + box-shadow: 0 1px 3px 1px rgba(60,64,67,.2), 0 2px 8px 4px rgba(60,64,67,.1); + background-color: #fff; } .privacy-banner__list { list-style: none; margin-bottom: 24px; } + button, .privacy-banner__btn { background-color:#191919; padding:8px 16px; color: #fff; border:0 none; cursor: pointer; + display: inline-block; + } + button + button { + margin-left: 12px; } + .privacy-banner__submit:hover, .privacy-banner__btn:hover { background-color: blue } + .privacy-banner__set-accept { + display: inline-block; + } + .privacy-banner__actions { + padding-top: 24px; + } + .privacy-banner__row { + margin-top: 12px; + } + .privacy-banner__legend { + padding-top: 12px; + } + .privacy-banner__form-title { + font-size: 1.2rem; + } + .privacy-banner__form-description { + display: block; + padding-top: 12px; + } + .privacy-banner__label-description { + display: block; + padding-left: 18px; + font-size: .85rem; + } .privacy-banner__form-announcement { position: absolute; opacity: 0; @@ -84,6 +145,9 @@ .privacy-banner__panel { display:none; } + .privacy-banner__submit { + margin-top: 12px; + } .on--preferences .privacy-banner__panel { display:block; } @@ -94,19 +158,19 @@ -

Update your cookie preferences to view this content.

-
-
-
-

Cookies

-

Cookies are a common feature used on almost all websites. A cookie is a small text file that is downloaded onto a device to allow a website to recognise it and to store some information about your preferences.

-

We use cookies for different reasons, including for managing your shopping cart, measuring your visits on our sites, recognising and remembering your preferences and showing you personalised ads. Learn more about cookies and how we use them.

-
- - - +
+
+

Update your cookie preferences to view this content.

+ +
+
+

Cookies

+

Cookies are a common feature used on almost all websites. A cookie is a small text file that is downloaded onto a device to allow a website to recognise it and to store some information about your preferences.

+

We use cookies for different reasons, including for managing your shopping cart, measuring your visits on our sites, recognising and remembering your preferences and showing you personalised ads. Learn more about cookies and how we use them.

+
+
diff --git a/packages/cookie-banner/example/src/js/index.js b/packages/cookie-banner/example/src/js/index.js index 948a469d..b195b740 100644 --- a/packages/cookie-banner/example/src/js/index.js +++ b/packages/cookie-banner/example/src/js/index.js @@ -36,7 +36,7 @@ const config = { ], types: { performance: { - suggested: 1, + // suggested: 1, 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: { @@ -59,7 +59,7 @@ const config = { ] }, ads: { - title: 'Set your personalised ads preferences', + title: 'Personalised ads preferences', description: 'We work with advertising partners to show you ads for our products and services across the web. You can choose whether we collect and share that data with our partners below. ', labels: { yes: 'Our partners might serve you ads knowing you have visited our website', @@ -80,7 +80,7 @@ const config = { } }, bannerTemplate(model){ - return ``; +
`; }, formTemplate(model){ return `
- ${Object.keys(model.settings.types).map(type => `
+ ${Object.keys(model.settings.types).map(type => `
${model.settings.types[type].title} ${model.settings.types[type].description} @@ -141,9 +141,8 @@ const config = {
`).join('')} -
+
-
Or
`; } @@ -157,6 +156,7 @@ window.addEventListener('DOMContentLoaded', () => { const banner = cookieBanner(config); [].slice.call(document.querySelectorAll('.js-preferences-update')).forEach(btn => btn.addEventListener('click', e => { + if (banner.getState().bannerOpen) return; banner.showBanner(); bannerToggle.startToggle(); })); diff --git a/packages/cookie-banner/src/index.js b/packages/cookie-banner/src/index.js index cd49c8be..3adc7b1b 100755 --- a/packages/cookie-banner/src/index.js +++ b/packages/cookie-banner/src/index.js @@ -1,7 +1,4 @@ import defaults from './lib/defaults'; import factory from './lib/factory'; -import { composeTypes } from './lib/utils'; -export default opts => factory(Object.assign({}, defaults, opts, { - types: Object.keys(opts.types).reduce(composeTypes(opts), defaults.types) -})); \ No newline at end of file +export default opts => factory(Object.assign({}, defaults, opts)); \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/consent.js b/packages/cookie-banner/src/lib/consent.js index 64f0cb17..1826da09 100644 --- a/packages/cookie-banner/src/lib/consent.js +++ b/packages/cookie-banner/src/lib/consent.js @@ -1,6 +1,6 @@ import { updateExecuted } from './reducers'; -export const applyEffects = (state) => { +export const applyEffects = state => { Object.keys(state.consent).forEach(key => { if (!state.settings.types[key]) return; if (state.settings.types[key].executed === true) return; @@ -8,14 +8,22 @@ export const applyEffects = (state) => { state.settings.types[key].fns.forEach(fn => fn(state)); } }); -} +}; -export const apply = Store => state => { +export const apply = store => state => { applyEffects(state); - Store.update(updateExecuted, Object.keys(state.settings.types).reduce((acc, type) => { - acc[type] = Object.assign({}, state.settings.types[type], { executed: state.settings.types[type].executed || (state.consent[type] && Boolean(state.consent[type])) }); - return acc; - }, {})); + store.update( + updateExecuted( + state, + Object.keys(state.settings.types).reduce((acc, type) => { + acc[type] = { + ...state.settings.types[type], + executed: state.settings.types[type].executed || (state.consent[type] && Boolean(state.consent[type])) + }; + return acc; + }, {}) + ) + ); }; export const necessary = state => { diff --git a/packages/cookie-banner/src/lib/defaults.js b/packages/cookie-banner/src/lib/defaults.js index 18ea9d69..f56fd4eb 100755 --- a/packages/cookie-banner/src/lib/defaults.js +++ b/packages/cookie-banner/src/lib/defaults.js @@ -33,7 +33,7 @@ export default { trapTab: false, savedMessage: 'Your settings have been saved.', bannerTemplate(model){ - return ``; +
`; }, messageTemplate(model){ return ``; diff --git a/packages/cookie-banner/src/lib/factory.js b/packages/cookie-banner/src/lib/factory.js index 0715f191..383179f2 100644 --- a/packages/cookie-banner/src/lib/factory.js +++ b/packages/cookie-banner/src/lib/factory.js @@ -1,45 +1,40 @@ -import { cookiesEnabled, extractFromCookie, noop, renderIframe, gtmSnippet, setGoogleConsent } from './utils'; +import { cookiesEnabled, extractFromCookie, renderIframe, gtmSnippet, setGoogleConsent } from './utils'; import { showBanner, initBanner, initForm, initBannerListeners, keyListener } from './ui'; import { necessary, apply } from './consent'; import { createStore } from './store'; -import { initialState } from './reducers'; export default settings => { /* istanbul ignore next */ if (!cookiesEnabled()) return; - const Store = createStore(); + const store = createStore(); - //extractFromCookie adds a try/catch guard for cookie reading and JSON.parse in case of cookie name collisions caused by versioning - //for sites that are saving the cookie consent in a different shape, i.e. without consent properties - //and for sites with cookies that are not base64 encoded const [ hasCookie, consent ] = extractFromCookie(settings); - Store.update( - initialState, + store.update( { settings, bannerOpen: false, - keyListener: keyListener(Store), + keyListener: keyListener(store), consent, utils: { renderIframe, gtmSnippet } }, [ necessary, - setGoogleConsent(Store, 'default'), - apply(Store), - hasCookie ? noop : initBanner(Store), - initForm(Store), - initBannerListeners(Store), - hasCookie ? setGoogleConsent(Store) : noop + setGoogleConsent(store, 'default'), + apply(store), + ...(hasCookie ? [] : [ initBanner(store) ]), + initForm(store), + initBannerListeners(store), + ...(hasCookie ? [ setGoogleConsent(store) ] : []) ] ); return { - getState: Store.getState, + getState: store.getState, showBanner(cb) { - showBanner(Store)(cb); - initBannerListeners(Store)(); + showBanner(store)(cb); + initBannerListeners(store)(); }, - renderForm: initForm(Store) + renderForm: initForm(store) }; }; \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/reducers.js b/packages/cookie-banner/src/lib/reducers.js index 0da27476..0415dbfa 100644 --- a/packages/cookie-banner/src/lib/reducers.js +++ b/packages/cookie-banner/src/lib/reducers.js @@ -1,12 +1,11 @@ -export const initialState = (state, data) => data; - export const updateBannerOpen = (state, data) => Object.assign({}, state, { bannerOpen: data -});; +}); export const updateBanner = (state, data) => Object.assign({}, state, { - banner: data -});; + banner: data.banner, + bannerOpen: data.bannerOpen +}); export const updateConsent = (state, data) => Object.assign({}, state, { consent: Object.assign({}, state.consent, data) diff --git a/packages/cookie-banner/src/lib/store.js b/packages/cookie-banner/src/lib/store.js index 3ab6facd..23bba601 100644 --- a/packages/cookie-banner/src/lib/store.js +++ b/packages/cookie-banner/src/lib/store.js @@ -1,25 +1,13 @@ export const createStore = () => { - //shared centralised validator state let state = {}; - //state getter const getState = () => state; - /** - * Create next state by invoking reducer on current state - * - * Execute side effects of state update, as passed in the update - * - * @param reducer [Function] - * @param nextState [Object] New slice of state to combine with current state to create next state - * @param effects [Array] Array of side effect functions to invoke after state update (DOM, operations, cmds...) - */ - const update = (reducer, nextState, effects) => { - state = reducer(state, nextState); - // console.log(state); + const update = (nextState, effects) => { + state = nextState ?? state; if (!effects) return; effects.forEach(effect => effect(state)); }; - + return { update, getState }; }; \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/ui.js b/packages/cookie-banner/src/lib/ui.js index d58d022f..b048d6d0 100644 --- a/packages/cookie-banner/src/lib/ui.js +++ b/packages/cookie-banner/src/lib/ui.js @@ -3,30 +3,34 @@ import { ACCEPTED_TRIGGERS, EVENTS } from './constants'; import { apply } from './consent'; import { updateConsent, updateBannerOpen, updateBanner } from './reducers'; -export const initBanner = Store => () => { - const state = Store.getState(); +export const initBanner = store => () => { + const state = store.getState(); if (state.bannerOpen || (state.settings.hideBannerOnFormPage && document.querySelector(`.${state.settings.classNames.formContainer}`))) return; document.body.firstElementChild.insertAdjacentHTML('beforebegin', state.settings.bannerTemplate(state.settings)); - Store.update(updateBanner, document.querySelector(`.${state.settings.classNames.banner}`)); - Store.update(updateBannerOpen, true, [ broadcast(EVENTS.SHOW, Store) ]); + store.update( + updateBanner(state, { + banner: document.querySelector(`.${state.settings.classNames.banner}`), + bannerOpen: true + }), + [ broadcast(EVENTS.SHOW, store) ] + ); window.dataLayer = window.dataLayer || []; - window.dataLayer.push({'event': 'stormcb_display'}); + window.dataLayer.push({ event: 'stormcb_display' }); }; -export const showBanner = Store => cb => { - initBanner(Store)(); - const { bannerOpen } = Store.getState(); - if (!bannerOpen) return; - initForm(Store)(); +export const showBanner = store => callback => { + initBanner(store)(); + if (!store.getState().bannerOpen) return; + initForm(store)(); const focusableChildren = getFocusableChildren(document.body.firstElementChild); if (focusableChildren.length > 0) focusableChildren[0].focus(); - if (cb && cb.call) cb(Store.getState()); + if (callback && callback.call) callback(store.getState()); }; -export const initBannerListeners = Store => () => { - const state = Store.getState(); +export const initBannerListeners = store => () => { + const state = store.getState(); const banner = state.banner; if (!banner) return; @@ -40,12 +44,12 @@ export const initBannerListeners = Store => () => { const analyticsObject = Object.entries(consentObject).reduce((acc, [key, value]) => { acc['stormcb_'+key] = value; return acc; - }, {'event': `stormcb_${event}_all`}); + }, { event: `stormcb_${event}_all` }); return { - consentObject: consentObject, - analyticsObject: analyticsObject - } - } + consentObject, + analyticsObject + }; + }; const acceptBtns = [].slice.call(document.querySelectorAll(composeSelector(state.settings.classNames.acceptBtn))); const rejectBtns = [].slice.call(document.querySelectorAll(composeSelector(state.settings.classNames.rejectBtn))); @@ -54,21 +58,22 @@ export const initBannerListeners = Store => () => { acceptBtns.forEach(acceptBtn => { acceptBtn.addEventListener('click', e => { - const {consentObject, analyticsObject} = composeConsentObjects('accept', 1); - Store.update( - updateConsent, - consentObject, + const { consentObject, analyticsObject } = composeConsentObjects('accept', 1); + const state = store.getState(); + store.update( + updateConsent(state, consentObject), [ + deleteCookies, writeCookie, - apply(Store), - removeBanner(Store), - initForm(Store, false), + apply(store), + removeBanner(store), + initForm(store), () => { window.dataLayer = window.dataLayer || []; window.dataLayer.push(analyticsObject); }, - broadcast(EVENTS.CONSENT, Store), - setGoogleConsent(Store), + broadcast(EVENTS.CONSENT, store), + setGoogleConsent(store), ] ); }); @@ -76,27 +81,26 @@ export const initBannerListeners = Store => () => { rejectBtns.forEach(rejectBtn => { rejectBtn.addEventListener('click', e => { - const {consentObject, analyticsObject} = composeConsentObjects('reject', 0); - Store.update( - updateConsent, - consentObject, + const { consentObject, analyticsObject } = composeConsentObjects('reject', 0); + const state = store.getState(); + store.update( + updateConsent(state, consentObject), [ writeCookie, - removeBanner(Store), - initForm(Store, false), + removeBanner(store), + initForm(store), () => { window.dataLayer = window.dataLayer || []; window.dataLayer.push(analyticsObject); }, - broadcast(EVENTS.CONSENT, Store), - setGoogleConsent(Store), + broadcast(EVENTS.CONSENT, store), + setGoogleConsent(store), ] ); }); }); }; - const trapTab = state => event => { const focusableChildren = getFocusableChildren(state.banner); const focusedIndex = focusableChildren.indexOf(document.activeElement); @@ -110,16 +114,16 @@ const trapTab = state => event => { } }; -export const keyListener = Store => event => { - if (Store.getState().banner && event.keyCode === 9) trapTab(Store.getState())(event); +export const keyListener = store => event => { + if (store.getState().banner && event.keyCode === 9) trapTab(store.getState())(event); }; -const removeBanner = Store => () => { - const state = Store.getState(); +const removeBanner = store => () => { + const state = store.getState(); const banner = state.banner; if (banner && banner.parentNode) { banner.parentNode.removeChild(banner); - Store.update(updateBannerOpen, false, [ broadcast(EVENTS.HIDE, Store) ]); + store.update(updateBannerOpen(state, false), [ broadcast(EVENTS.HIDE, store) ]); } if (state.settings.trapTab) document.removeEventListener('keydown', state.keyListener); }; @@ -133,8 +137,8 @@ const suggestedConsent = state => Object.keys(state.consent).length > 0 }, {}) }); -export const initForm = (Store, track = true) => () => { - const state = Store.getState(); +export const initForm = store => () => { + const state = store.getState(); const formContainer = document.querySelector(`.${state.settings.classNames.formContainer}`); if (!formContainer) return; @@ -162,13 +166,13 @@ export const initForm = (Store, track = true) => () => { const analyticsObject = Object.entries(consentObject).reduce((acc, [key, value]) => { acc['stormcb_'+key] = value; return acc; - }, {'event': `stormcb_save`}); + }, { event: `stormcb_save` }); return { - consentObject: consentObject, - analyticsObject: analyticsObject - } - } + consentObject, + analyticsObject + }; + }; const enableButton = e => { if (Object.keys(extractConsentObjects().consentObject).length !== Object.keys(groups).length) return; @@ -179,23 +183,23 @@ export const initForm = (Store, track = true) => () => { form.addEventListener('submit', event => { event.preventDefault(); - const {consentObject, analyticsObject} = extractConsentObjects(); - Store.update( - updateConsent, - consentObject, + const { consentObject, analyticsObject } = extractConsentObjects(); + const state = store.getState(); + store.update( + updateConsent(state, consentObject), [ deleteCookies, writeCookie, - apply(Store), - removeBanner(Store), + apply(store), + removeBanner(store), () => { window.dataLayer = window.dataLayer || []; window.dataLayer.push(analyticsObject); }, - broadcast(EVENTS.CONSENT, Store), + broadcast(EVENTS.CONSENT, store), renderMessage(button), renderAnnouncement(formAnnouncement), - setGoogleConsent(Store), + setGoogleConsent(store), ] ); }); diff --git a/packages/cookie-banner/src/lib/utils.js b/packages/cookie-banner/src/lib/utils.js index 9cc20756..c1c5fc03 100755 --- a/packages/cookie-banner/src/lib/utils.js +++ b/packages/cookie-banner/src/lib/utils.js @@ -33,9 +33,9 @@ export const readCookie = settings => { return false; }; -const updateCookie = state => model => document.cookie = [ - `${model.name}=${model.value};`, - `expires=${model.expiry};`, +const updateCookie = (state, cookie) => document.cookie = [ + `${cookie.name}=${cookie.value};`, + `expires=${cookie.expiry};`, `path=${state.settings.path};`, state.settings.domain ? `domain=${state.settings.domain};` : '', state.settings.samesite ? `SameSite=${state.settings.samesite};` : '', @@ -50,33 +50,30 @@ export const deleteCookies = state => { value: part.split('=')[1], expiry: 'Thu, 01 Jan 1970 00:00:01 GMT' })) - .map(updateCookie(state)); + .map(cookie => updateCookie(state, cookie)); }; -//@return array [hasCookie, consent<{}>] export const extractFromCookie = settings => { try { const cookie = readCookie(settings); if (!cookie) return [false, {}]; const { consent } = JSON.parse(cookie); const hasCookie = consent !== undefined; + if (!categoriesMatch(Object.keys(consent), Object.keys(settings.types))) return [false, {}]; return [hasCookie, consent || {}]; } catch (e){ return [false, {}]; } }; -export const composeTypes = opts => (acc, curr) => { - if (acc[curr]) { - acc[curr] = Object.assign({}, acc[curr], { - fns: acc[curr].fns.concat(opts.types[curr].fns), - }); - } else acc[curr] = opts.types[curr]; - return acc; +const categoriesMatch = (found, categories) => { + if (found.length !== categories.length) return false; + for (const category of categories) { + if (found.indexOf(category) === -1) return false; + } + return true; }; -export const noop = () => {}; - export const isCheckable = field => (/radio|checkbox/i).test(field.type); const hasValue = input => (input.value !== undefined && input.value !== null && input.value.length > 0); @@ -110,11 +107,11 @@ export const removeSubdomain = s => { export const getFocusableChildren = node => [].slice.call(node.querySelectorAll(FOCUSABLE_ELEMENTS.join(','))).filter(el => el.offsetWidth > 0 || el.offsetHeight > 0); -export const broadcast = (type, Store) => () => { +export const broadcast = (type, store) => () => { const event = new CustomEvent(type, { bubbles: true, detail: { - getState: Store.getState + getState: store.getState } }); window.document.dispatchEvent(event); @@ -157,8 +154,8 @@ function gtag() { window.dataLayer.push(arguments); } -export const setGoogleConsent = (Store, pushType = 'update') => () => { - const { settings, consent } = Store.getState(); +export const setGoogleConsent = (store, pushType = 'update') => () => { + const { settings, consent } = store.getState(); const { euConsentTypes } = settings; if (!euConsentTypes) return;