Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Civic uk: Accept/reject behaviour #10685

Merged
merged 15 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@ export const getAnalyticsConsentState = (
const consentCookie = cookies.CookieControl;

// Ensures this returns true for regular users
// that don't have the "Cookie works" toggle on/have defined the consent cookie
if (isCookiesWorkToggleOn && consentCookie !== undefined) {
const civicUKCookie: CivicUKCookie = JSON.parse(
decodeURIComponent(consentCookie)
);
return civicUKCookie.optionalCookies?.analytics === 'accepted';
// that don't have the "Cookie works" toggle on
if (isCookiesWorkToggleOn) {
// If the feature flag is ON and consent has been defined,
// return its value
if (consentCookie !== undefined) {
const civicUKCookie: CivicUKCookie = JSON.parse(
decodeURIComponent(consentCookie)
);

return civicUKCookie.optionalCookies?.analytics === 'accepted';
} else {
// If the feature flag is ON but consent has yet to be defined
return false;
}
} else {
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future we'll need the default no consent state to be false, worth adding a comment to be double sure we do that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we remove the toggle and therefore the first condition, and only keep the second condition, with a change to the comments, that's what it'll do 👍

if (consentCookie !== undefined) {
    const civicUKCookie: CivicUKCookie = JSON.parse(
      decodeURIComponent(consentCookie)
    );

    return civicUKCookie.optionalCookies?.analytics === 'accepted';
  } else {
    // If consent has yet to be defined, return false by default
    return false;
  }
}

We'll for sure test that a lot, it's why we'll requisition e2e for a bit, with all the toggle stuff out of the way 🤞

}
Expand Down
41 changes: 32 additions & 9 deletions common/services/app/google-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ type Props = {
data: {
toggles?: Toggles;
};
hasAnalyticsConsent: boolean;
};

// We send toggles as an event parameter to GA4 so we can determine the condition in which a particular event took place.
// GA4 now limits event parameter values to 100 characters: https://support.google.com/analytics/answer/9267744?hl=en
// So instead of sending the whole toggles JSON blob, we only look at the "test" typed toggles and send a concatenated string made of the toggles' name
// , preceeded with a! if its value is false.
function createToggleString(toggles: Toggles | undefined): string | null {
function createABToggleString(toggles: Toggles | undefined): string | null {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I gathered, this only returns AB test strings, so I renamed to clarify.

const testToggles = toggles
? Object.keys(toggles).reduce((acc, key) => {
if (toggles?.[key].type === 'test') {
Expand All @@ -42,18 +43,40 @@ function createToggleString(toggles: Toggles | undefined): string | null {
: null;
}

export const Ga4DataLayer: FunctionComponent<Props> = ({ data }) => {
const toggleString = createToggleString(data.toggles);
export const Ga4DataLayer: FunctionComponent<Props> = ({
data,
hasAnalyticsConsent,
}) => {
const abTestsToggleString = createABToggleString(data.toggles);

return toggleString ? (
return data.toggles?.cookiesWork?.value || abTestsToggleString ? (
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
toggles: '${toggleString}'
});
`,
window.dataLayer = window.dataLayer || [];

${
data.toggles?.cookiesWork?.value
? `function gtag(){window.dataLayer.push(arguments);}

gtag('consent', 'default', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we need some more fields here to adhere to "consent mode v2": https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#upgrade-consent-v2, specifically ad_user_data and ad_personalization.

I expect we'll just hard-code the values to denied though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yess I wasn't sure we used those because adding them caused me TS errors, looks like the gtag helpers don't know about them?

No overload matches this call.
  The last overload gave the following error.
    Object literal may only specify known properties, and 'ad_personalization' does not exist in type 'ConsentParams'.
Screenshot 2024-03-08 at 11 48 54

That being said, as I'm looking at this, it's only a problem in the TS code when we update the values, and not on the default/initialisation one. And we should only change the analytics_storage value if they allow analytics and the others should be denied from the get go, including ad_storage, which I currently mark as granted if analytics is. So I'll change that to add everything to the consent initialisation and only change analytics_storage on consent given.

If ever we want those to be changeable, we'll deal with TS errors then. Not a problem for now. I'll stop thinking out loud and apply changes 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can review this on Monday but changes applied:

(default setting)
Screenshot 2024-03-08 at 12 05 12

(on rescinding consent)
Screenshot 2024-03-08 at 12 05 24

'analytics_storage': ${
hasAnalyticsConsent ? '"granted"' : '"denied"'
},
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied'
});`
: ``
}

${
abTestsToggleString &&
`window.dataLayer.push({
toggles: '${abTestsToggleString}'
});`
}
`,
}}
/>
) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ const CivicUK = () => (
'__utmv',
],
onAccept: function () {
const event = new CustomEvent('analyticsConsentChange', {});
const event = new CustomEvent('analyticsConsentChanged', { detail: { consent: 'granted' }});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

window.dispatchEvent(event);
},
onRevoke: function () {
const event = new CustomEvent('analyticsConsentChange', {});
const event = new CustomEvent('analyticsConsentChanged', { detail: { consent: 'denied' } });
window.dispatchEvent(event);
},
},
Expand Down
50 changes: 50 additions & 0 deletions common/views/components/ConsentAndScripts/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Head from 'next/head';
import { useEffect, useState } from 'react';
import CivicUK from './ConsentAndScripts.CivicUK';
import { getAnalyticsConsentState } from '@weco/common/services/app/civic-uk';

const ConsentAndScripts = ({ segmentSnippet }: { segmentSnippet: string }) => {
const [consentState, setConsentState] = useState(false);

const onAnalyticsConsentChanged = (
event: CustomEvent<{ consent: 'granted' | 'denied' }>
) => {
// Toggle rendering of scripts
setConsentState(event.detail.consent === 'granted');

// Update datalayer config with consent value
gtag('consent', 'update', {
analytics_storage: event.detail.consent,
});
};

useEffect(() => {
setConsentState(getAnalyticsConsentState());

window.addEventListener(
'analyticsConsentChanged',
onAnalyticsConsentChanged
);

return () => {
window.removeEventListener(
'analyticsConsentChanged',
onAnalyticsConsentChanged
);
};
}, []);

return (
<>
{consentState && (
<Head>
<script dangerouslySetInnerHTML={{ __html: segmentSnippet }} />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally also put the GTM script in this conditional, but I was experiencing what I think is this Next bug where on loading it/removing it/re-adding it, random meta tags would duplicate themselves, even if they had a key. Someone mentioned that for them it only happened when GTM was enabled, and it does seem to be the case here as well.

I think it's ok though as we set the consent to denied, so no hits are being sent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so no hits are being sent.

I believe the tag still "phones home" via what the docs describe as a "cookie-less ping".

There is the possibly contentious case for adding the GTM script before consent that we still record some analytics data about these users in a legally compliant manner (so long as we set the consent state in the data layer correctly). See https://support.google.com/tagmanager/answer/13802165?sjid=2210990408479536825-EU for more details about what gets communicated to Google in the situation consent is denied.

See "Behavioral modeling for consent mode" for one of the "features" available to us. Notably modelled data for un-consented visitors is not sent via any BigQuery integration.

We should be sure we're comfortable with this, and check with @LaurenFBaily.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a look at this and the documentation, I'm happy with that.

</Head>
)}

<CivicUK />
</>
);
};

export default ConsentAndScripts;
1 change: 1 addition & 0 deletions common/views/components/Footer/Footer.Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const FooterNav = ({
<li>
<Buttons
variant="ButtonSolid"
dataGtmTrigger="consent_test_btn"
text="Cookie preference centre"
clickHandler={() => {
window.CookieControl.open();
Expand Down
38 changes: 29 additions & 9 deletions common/views/components/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,22 @@ const PageLayoutComponent: FunctionComponent<Props> = ({
<>
<Head>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta key="metadescription" name="description" content={description} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful to add a comment linking to the next issue describing why we're adding key here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not here, as the Next issue is saying that this should work but doesn't. It's a recommendation from their official documentation for next/head, so "normal".

If we wanted to keep track of that issue I could add it to where we load the GTM script and explain that's why we're not removing it?

<link rel="canonical" href={absoluteUrl} />
{/* meta elements need to be contained as direct children of the Head element, so don't componentise the following */}
<meta property="og:site_name" content="Wellcome Collection" />
<meta property="og:type" content={openGraphType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={absoluteUrl} />
<meta
key="og:sitename"
property="og:site_name"
content="Wellcome Collection"
/>
<meta key="og:type" property="og:type" content={openGraphType} />
<meta key="og:title" property="og:title" content={title} />
<meta
key="og:description"
property="og:description"
content={description}
/>
<meta key="og:url" property="og:url" content={absoluteUrl} />
<meta
key="og:image"
property="og:image"
Expand Down Expand Up @@ -207,9 +215,21 @@ const PageLayoutComponent: FunctionComponent<Props> = ({
content={description}
/>
<meta key="twitter:image" name="twitter:image" content={imageUrl} />
<meta name="twitter:image:alt" content={imageAltText} />
<meta httpEquiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
key="twitter:image:alt"
name="twitter:image:alt"
content={imageAltText}
/>
<meta
key="httpEquiv"
httpEquiv="X-UA-Compatible"
content="IE=edge,chrome=1"
/>
<meta
key="viewport"
name="viewport"
content="width=device-width, initial-scale=1"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
Expand Down
26 changes: 11 additions & 15 deletions common/views/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NextPage } from 'next';
import { AppProps } from 'next/app';
import React, { useEffect, FunctionComponent, ReactElement } from 'react';
import { ThemeProvider } from 'styled-components';
Expand All @@ -22,9 +23,10 @@ import { AppErrorProps } from '@weco/common/services/app';
import usePrismicPreview from '@weco/common/services/app/usePrismicPreview';
import useMaintainPageHeight from '@weco/common/services/app/useMaintainPageHeight';
import { GaDimensions } from '@weco/common/services/app/google-analytics';
import { NextPage } from 'next';
import { deserialiseProps } from '@weco/common/utils/json';
import { SearchContextProvider } from '@weco/common/views/components/SearchContext/SearchContext';
import ConsentAndScripts from '@weco/common/views/components/ConsentAndScripts';
import { renderSegmentSnippet } from './_document';

// Error pages can't send anything via the data fetching methods as
// the page needs to be rendered as soon as the error happens.
Expand Down Expand Up @@ -83,23 +85,10 @@ const WecoApp: FunctionComponent<WecoAppProps> = ({

const serverData = isServerDataSet ? pageProps.serverData : defaultServerData;

const onAnalyticsConsentChange = () => {
// Have popup that says "Your cookie settings have been saved"
};

useMaintainPageHeight();
useEffect(() => {
document.documentElement.classList.add('enhanced');
}, []);

useEffect(() => {
window.addEventListener('analyticsConsentChange', onAnalyticsConsentChange);
return () => {
window.removeEventListener(
'analyticsConsentChange',
onAnalyticsConsentChange
);
};
document.documentElement.classList.add('enhanced');
}, []);

useEffect(() => {
Expand Down Expand Up @@ -133,6 +122,13 @@ const WecoApp: FunctionComponent<WecoAppProps> = ({
isFontsLoaded={useIsFontsLoaded()}
/>
<LoadingIndicator />

{pageProps.serverData?.toggles?.cookiesWork?.value && (
<ConsentAndScripts
segmentSnippet={renderSegmentSnippet()}
/>
)}

{!pageProps.err &&
getLayout(<Component {...deserialiseProps(pageProps)} />)}
{pageProps.err && (
Expand Down
31 changes: 17 additions & 14 deletions common/views/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@ import {
GoogleTagManager,
GaDimensions,
} from '@weco/common/services/app/google-analytics';
import CivicUK from '@weco/common/services/app/civic-uk/scripts';

const {
ANALYTICS_WRITE_KEY = '78Czn5jNSaMSVrBq2J9K4yJjWxh6fyRI',
NODE_ENV = 'development',
} = process.env;

function renderSegmentSnippet() {
export function renderSegmentSnippet() {
const opts = {
apiKey: ANALYTICS_WRITE_KEY,
page: false,
Expand Down Expand Up @@ -79,22 +78,26 @@ class WecoDoc extends Document<DocumentInitialPropsWithTogglesAndGa> {
return (
<Html lang="en">
<Head>
{this.props.toggles?.cookiesWork?.value && <CivicUK />}
<>
{/* Adding toggles etc. to the datalayer so they are available to events in Google Tag Manager */}
<Ga4DataLayer
hasAnalyticsConsent={this.props.hasAnalyticsConsent}
data={{
toggles: this.props.toggles,
}}
/>

{this.props.hasAnalyticsConsent && (
<>
{/* Adding toggles etc. to the datalayer so they are available to events in Google Tag Manager */}
<Ga4DataLayer
data={{
toggles: this.props.toggles,
}}
/>
<GoogleTagManager />
{/* Removing/readding this script on consent changes causes issues with meta tag duplicates
https://github.com/wellcomecollection/wellcomecollection.org/pull/10685#discussion_r1516298683
Let's keep an eye on this issue and consider moving it next to the Segment script when it's fixed */}
<GoogleTagManager />

{!this.props.toggles?.cookiesWork?.value && (
<script
dangerouslySetInnerHTML={{ __html: renderSegmentSnippet() }}
/>
</>
)}
)}
</>
</Head>
<body>
<div id="top">
Expand Down
Loading