Perquè i com?

Tot va venir por uns correus de google analyitics indicant que hi havia canvis a la plataforma i calia migrar la informació. va ser llavors quan va arribar el moment d’afegir un bàner de consentiment de galetes. Volia alguna cosa lleugera: seria una llàstima crear un lloc Jekyll net amb un munt de Javascript! (😅 més del que ja porta). El 🚀 consentiment de les galetes Jekyll és semblant al que buscava, però volia oferir als visitants l’opció de personalitzar quines galetes volien acceptar (p. ex., anàlisi de llocs web i personalització d’anuncis). Per si algun dia se m’acudeix afegir anincis 🤓.
Finalment he triat seguir 🚀 Lightweight Cookie Consent for Jekyll Funciona carregant la pàgina amb el codi de galetes quan el visitant hi ha donat el consentiment previ. Sinó t’ho demanarà. És una solució que funciona de manera invisible amb el JavaScript subjacent en segon pla per no interrompre l’experiència de l’usuari si no és necessari.

Afortunadament, Google ha llançat una nova funció “Mode de consentiment” que facilita aquest procés. Documentació per al 📚 mode de consentiment de gtag. Amb la funció de mode de consentiment, especifiqueu un valor predeterminat per a si s’utilitzen galetes per a anuncis o per a analítiques, que podeu actualitzar quan un visitant accepti o canviï les seves preferències amb una simple conexió a gtag(). Fins i tot podeu especificar diferents valors predeterminats per regió, per exemple per complir amb les estrictes regulacions de consentiment primer a Europa mentre apliqueu una política d’exclusió més liberal en altres jurisdiccions.

Codi del bàner

El primer que cal dir és que pel meu lloc web uso el “framework” Codiframe en la seva versió 4 però amb alguns afegitions 😇. Una mica de javascreipt per aquí, una punteta de sass per allà… Vinc a dir que és una versió vitaminada de l’original. Anem per feina. Ho he acabat fincant tot a l’extensió google-analytics.html del meu jekyll.

Bloc HTML del bàner de consentiment

<div id="cookie-consent" aria-label="Cookie consent banner" class="js-collapse" data-collapse-animate="on">
    <div class="padding-sm bg-dark">
        <div class="text-component">
            <p>Utilitzem cookies per entendre com els visitants utilitzen aquest lloc i per millorar l'experiència dels anuncis. Si vols saber més clica <a class="cookie-consent-btn " href="/privacy">aquí</a></p>
            <div class="btns inline-flex flex-wrap gap-xs">
                <button id="cookie-consent-dismiss-btn" class="btn btn--accent">D'acord</button>
                <button id="cookie-consent-customize-btn" class="btn btn--accent-subtle" aria-controls="cookie-consent-customize">Personalitzar</button>
            </div>
            [...] LA TAULA DE SWITCHES VA AQUI [...]
        </div>
    </div>
</div>

El detall de la taula de interruptors (switches) recordeu que està adaptada al meu “framework”:

<div id="cookie-consent-customize" class="hide js-collapse" data-collapse-animate="on">
    <div class="container max-width-md">
        <div class="tbl settings-tbl space-unit-em">
            <table class="tbl__table text-sm border-bottom border-2" aria-label="Table Consent Customize">
                <thead class="tbl__header border-bottom border-2">
                    <tr class="tbl__row">
                        <th class="tbl__cell text-left" scope="col" style="text-align: left !important;">Seleccioneu quines galetes accepteu:</th>
                        <th class="sr-only" scope="col">Enable/disable option</th>
                    </tr>
                </thead>
                <tbody class="tbl__body">
                    <tr class="tbl__row">
                        <td class="tbl__cell" role="cell">
                            <p>Estadístiques d'ús del lloc web</p>
                        </td>
                        <td class="tbl__cell" role="cell">
                            <div class="flex justify-end">
                                <div class="switch">
                                    <input class="switch__input" type="checkbox" id="cookie-consent-analytics" >
                                    <label class="switch__label" for="cookie-consent-analytics" aria-hidden="true">Estadístiques d'ús del lloc web</label>
                                    <div class="switch__marker" aria-hidden="true"></div>
                                </div>
                            </div>
                        </td>
                    </tr>
                    <tr class="tbl__row">
                        <td class="tbl__cell" role="cell">
                            <p>Ad personalization ADS</p>
                        </td>
                        <td class="tbl__cell" role="cell">
                            <div class="flex justify-end">
                                <div class="switch">
                                    <input class="switch__input" type="checkbox" id="cookie-consent-ads" >
                                    <label class="switch__label" for="cookie-consent-ads" aria-hidden="true">Ad personalization ADS</label>
                                    <div class="switch__marker" aria-hidden="true"></div>
                                </div>
                            </div>
                        </td>
                    </tr>
                    <tr class="tbl__row">
                        <td class="tbl__cell" role="cell">
                            <p>Personalització d'anuncis</p>
                        </td>
                        <td class="tbl__cell" role="cell">
                            <div class="flex justify-end">
                                <div class="switch">
                                    <input class="switch__input" type="checkbox" id="cookie-consent-user-data" >
                                    <label class="switch__label" for="cookie-consent-user-data" aria-hidden="true">Personalització d'anuncis</label>
                                    <div class="switch__marker" aria-hidden="true"></div>
                                </div>
                            </div>
                        </td>
                    </tr>
                    <tr class="tbl__row">
                        <td class="tbl__cell" role="cell">
                            <p>Dades de l'usuari als anuncis</p>
                        </td>
                        <td class="tbl__cell" role="cell">
                            <div class="flex justify-end">
                                <div class="switch">
                                    <input class="switch__input" type="checkbox" id="cookie-consent-personalization">
                                    <label class="switch__label" for="cookie-consent-personalization" aria-hidden="true">Dades de l'usuari als anuncis</label>
                                    <div class="switch__marker" aria-hidden="true"></div>
                                </div>
                            </div>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</div>

A la capçalera de les pàgines hi haurem de posar la condició per carregar la plantilla: que estiguis en mode producció i que dispossis d’un identificador de mesurament de Google.


  {%- if jekyll.environment == 'production' and site.google_analytics -%}
    {%- include extensions/google-analytics.html -%}
  {%- endif -%}

Per si teniu algun dubte, per posar Jekyll en mode “production” (a mi em va pasar), tenint en compte que utilitzo un mac, des del terminal caldrà executar:

export JEKYLL_ENV=production

i tot seguit, des del directori on tenim el jekyll:

bundle exec jekyll clean
bundle exec jekyll build

El bàner consta d’un breu missatge explicatiu i tres botons: un botó per acceptar les cookies, un enllaç a la política de privadesa i un botó per personalitzar quines cookies vol acceptar o denegar l’usuari. A continuació, un bloc de personalització (cookie-consent-customize) amb controls per a cada tipus de galetes (aquí, anàlisi de llocs web i personalització d’anuncis). El bloc de personalització està amagat per defecte.

En fer clic al botó de consentiment s’anomena la funció recordConsent() (a sota) per registrar les opcions del visitant i ocultar el bloqueig de consentiment. En fer clic al botó “Personalitzar” es mostra el bloc de personalització i s’amaga el botó “Personalitzar”, ja que ja no té una funció un cop es mostra el bloc de personalització.

Els selectors de cada tipus de galetes són personalitzats pel “framework” Codiframe però es poden emprar 🎉 “switches” en pur css w3schools. El control lliscant consisteix simplement en un bloc de fons ple de classe .slider (per crear la pista per a l’interruptor) i un bloc de primer pla ple adjunt (per representar el commutador) implementat amb .slider:before. El control lliscant està enllaçat amb una entrada de casella de selecció invisible. Quan es marca l’entrada, la forma de commutació es tradueix a la dreta i el color de fons canvia de gris a verd. Una ombra afegida indica quan el control té el focus del teclat (possible amb l’índex de tabulació: 0). Tot això s’aconsegueix mitjançant CSS:

Els fulls d’estil SCSS

:root {
    --switch-width: 64px;
    --switch-height: 32px;
    --switch-padding: 3px;
    --switch-animation-duration:0.2s;
}

.switch {
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    width: var(--switch-width);
    height: var(--switch-height);
    border-radius: 50em;
    padding:var(--switch-padding) 0;
    &__input,
    &__label {
        position: absolute;
        left: 0;
        top:0;
    }
    &__input {
        margin: 0;
        padding: 0;
        opacity: 0;
        height: 0;
        width: 0;
        pointer-events:none;
    }
    &__input:checked + &__label {
        background-color:var(--color-primary);
    }
    
    &__input:checked + &__label + &__marker {
        left:calc(100% - var(--switch-height) + var(--switch-padding));
    }
    &__input:focus + &__label, 
    &__input:active + &__label {
        box-shadow:0 0 0 2px hsla(var(--color-contrast-higher-h), var(--color-contrast-higher-s), var(--color-contrast-higher-l), 0.2);
    }
    &__input:checked:focus + &__label, 
    &__input:checked:active + &__label {
        box-shadow:0 0 0 2px hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.2);
    }
    &__label {
        width: 100%;
        height: 100%;
        color: transparent;
        -webkit-user-select: none;
        user-select: none;
        background-color: var(--color-bg-darker);
        border-radius: inherit;
        z-index: 1;
        transition: var(--switch-animation-duration);
        overflow:hidden;
    }
    &__marker {
        position: relative;
        background-color: var(--color-white);
        width: calc(var(--switch-height) - var(--switch-padding) * 2);
        height: calc(var(--switch-height) - var(--switch-padding) * 2);
        border-radius: 50%;
        z-index: 2;
        pointer-events: none;
        box-shadow: var(--shadow-xs);
        left: var(--switch-padding);
        transition: left var(--switch-animation-duration);
        will-change: left;
    }
}

Per aconseguir que la pancarta s’enganxi a la part inferior de la pantalla, utilitzem la posició: fixa z-índex:999 que col·loca la pancarta a sobre de tot el que es desplaça per sota:

Full d’estil de bàner de consentiment

    #cookie-consent{
        position: fixed;
        width: 100%;
        bottom: 0; left: 0;
        z-index: 999;
        @media print { #cookie-consent { display: none; } }
    }

Ara toca el javascript

La funció recordConsent() estableix cinc galetes: una per a cada tipus de consentiment (analítica, emmagatzemament i anuncis), així com una galeta de conveniència que indica que el bàner de consentiment s’ha descartat. A continuació, crida a gtag() amb la configuració de consentiment actualitzada.

La funció getCookie() prové de la implementació Jekyll Codex i simplement cerca a través de les galetes d’aquesta pàgina el valor anomenat.

La funció setCookie() estableix el valor de le galetes amb una vida útil d’un any (365 dies en milisegons).

La funció updateGtag() actualitza l’enviament a gtag().

Ara cal lligar-ho tot. Augmentem el codi gtag típic amb la configuració de consentiment predeterminada i, a continuació, comprovem si l’usuari ha descartat el bàner de consentiment (if (getCookie('cookie_consent_cleared')) comproba si ja s’ha triat i hi ha la galeta disponible). Si és així, apliquem la configuració del consentiment de l’usuari. Tot això passa abans de cridar a gtag(‘config’) de manera que qualsevol configuració desada d’una vista de pàgina anterior s’aplicarà des de l’inici.

Hi ha una part on defineixo les variables que identifiquen els controls: btn... son botons i cbx...son els “switches”

<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>
<script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}

    gtag('consent', 'default', {
        'ad_storage': 'denied',
        'ad_user_data': 'denied',
        'ad_personalization': 'denied',
        'analytics_storage': 'denied'
    });
    gtag('consent', 'default', { 'analytics_storage': 'denied', 'region': ['EU', 'UK'] });

    gtag('js', new Date());
    gtag('config', '{{ site.google_analytics }}');

    (function() {
        var btnDismiss = document.getElementById('cookie-consent-dismiss-btn'),
            btnCustomize = document.getElementById('cookie-consent-customize-btn'),
            panelConsent = document.getElementById('cookie-consent'),
            cbxAds = document.getElementById('cookie-consent-ads'),
            cbxAnalytics = document.getElementById('cookie-consent-analytics'),
            cbxUser = document.getElementById('cookie-consent-user-data'),
            cboxPersonalization = document.getElementById('cookie-consent-personalization');
        
        if (getCookie('cookie_consent_cleared')) {
            panelConsent.classList.add("hide");
            cbxAds.checked = getCookie('cookie_consent_ads')=="true"? true : false;
            cbxAnalytics.checked = getCookie('cookie_consent_analytics')=="true"? true : false;
            cbxUser.checked = getCookie('cookie_consent_user_data')=="true"? true : false;
            cboxPersonalization.checked=getCookie('cookie_consent_personalization')=="true"? true : false;
            
            updateGtag();
        } else {
            cbxAnalytics.checked = true
        }

        btnDismiss.addEventListener('click', function(){
            recordConsent();
            panelConsent.classList.add("hide");
        });

        function getCookie(cname) {
            let name = cname + "=";
            let ca = document.cookie.split(';');
            for(let i = 0; i < ca.length; i++) {
              let c = ca[i];
              while (c.charAt(0) == ' ') {
                c = c.substring(1);
              }
              if (c.indexOf(name) == 0) {
                return c.substring(name.length, c.length);
              }
            }
            return "";
        };
        
        function setCookie(cname, cvalue, exdays) {
            const d = new Date();
            d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
            let expires = "expires="+d.toUTCString();
            document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
        };

        function recordConsent() {
            let exdays = 365 // One year from now by default
            setCookie("cookie_consent_ads", cbxAds.checked, exdays );
            setCookie("cookie_consent_analytics", cbxAnalytics.checked, exdays );
            setCookie("cookie_consent_user_data", cbxUser.checked, exdays );
            setCookie("cookie_consent_personalization", cboxPersonalization.checked, exdays );
            setCookie("cookie_consent_cleared", "true", exdays );

            updateGtag();
        };

        function updateGtag() {
            gtag('consent', 'update', {
                'ad_storage': (cbxAds.checked ? 'granted' : 'denied'),
                'analytics_storage': (cbxAnalytics.checked ? 'granted' : 'denied'),
                'ad_user_data': (cbxUser.checked ? 'granted' : 'denied'),
                'ad_personalization': (cboxPersonalization.checked ? 'granted' : 'denied')
            });
        }
    }());      
</script>

Tot i que Google recomana posar gtag(‘config’) a <head>, aquest bloc ha d’anar després del bloc de consentiment de galetes perquè la secció del mig pugui llegir l’estat dels elements de la casella de selecció.

Nota: no sóc un expert legal de GDPR.

Google Analytics assigna un identificador aleatori a cada visitant que és únic per al lloc web específic que s’està analitzant. A menys que un dissenyador de llocs web incorpori de manera explícita (o negligent) informació d’identificació al flux d’anàlisi (per exemple, després que l’usuari iniciï sessió), aquests “identificadors” són, a tots els efectes, anònims. No tinc ni idea de la identitat de l’usuari “1066186681.1615103843” de Parsons, Kansas (9.736 habitants). No obstant això, aparentment, els tribunals europeus consideren aquests identificadors aleatoris com a “informació d’identificació personal” per a la qual es necessita un consentiment positiu abans que puguin ser enviats a un tercer per al seu tractament d’acord amb el Reglament General de Protecció de Dades (GDPR). Com a tal, per a la configuració predeterminada, vaig optar per denegar les galetes d’anàlisi només per als visitants de la UE i el Regne Unit (permetent que els usuaris d’altres regions puguin desactivar-se). Per a la personalització dels anuncis, en canvi, vaig optar per esperar fins que els usuaris de qualsevol regió proporcionin el consentiment positiu perquè alguns usuaris poden trobar anuncis personalitzats una mica esgarrifosos.

Finalment, els usuaris necessiten un mitjà per revocar el seu consentiment un cop donat. Això només requereix tornar a mostrar el bloc de personalització del bàner de consentiment per permetre als usuaris canviar les seves preferències i tornar a gravar el resultat. Ho aconsegueixo amb un simple botó a la pàgina de privadesa (privacy.md) donat que el marc de consentiment està sempre present però ocult.

<button class="btn btn--subtle" aria-controls="cookie-consent" >Actualitza la configuració de les galetes</button>

Quan un usuari fa clic al botó, es mostren el bloc de personalització i els blocs de consentiment, el botó de personalització s’amaga i el focus del teclat es configura al botó “D’acord” al bàner de consentiment. Definir el focus és important, aquí, per als visitants que utilitzen lectors de pantalla. Sense això, no hi hauria cap retroalimentació detectable que indiqui que s’hagués produït una acció quan es va activar el botó, i els usuaris podrien tenir dificultats per saber que han de navegar cap enrere per trobar el bàner de consentiment al començament de la pàgina (dOM). L’etiqueta aria per al bàner de consentiment també ajuda aquí a fer saber a l’usuari que aquest botó “D’acord” es troba en el context del bàner de consentiment.

I allà el tenim! El paquet arriba a uns 11 kB, que probablement es podria reduir una mica per minificació. Tot plegat, crec que una solució bastant lleugera.